diff --git a/README.md b/README.md
index e2a808508..c2c9a60aa 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,261 @@
-# Cross Platform
+
+
+
+# Raven Mobile App
+
+**Caw Your Thoughts**
+
+A modern, cross-platform social media application built with React Native and Expo.
+
+[](https://reactnative.dev/)
+[](https://expo.dev/)
+[](https://www.typescriptlang.org/)
+
+
+
+## Features
+
+### Authentication & Security
+
+- **Email/Password Authentication** - Secure sign-up and sign-in with email verification
+- **OAuth Integration** - Third-party authentication support
+- **Password Recovery** - Forgot password flow with email-based reset
+- **Multi-Account Support** - Account switcher for managing multiple profiles
+- **Secure Token Storage** - Uses Expo Secure Store for sensitive data
+
+### Timeline & Feed
+
+- **Home Timeline** - Personalized feed with posts from followed users
+- **Real-time Updates** - Live feed updates via Server-Sent Events (SSE)
+- **Infinite Scrolling** - Cursor-based pagination for smooth scrolling
+- **Pull-to-Refresh** - Refresh feed with pull gesture
+
+### Tweets/Posts
+
+- **Tweet Composer** - Rich text editor with support for:
+ - Hashtag and mention highlighting
+ - Emoji picker integration
+ - Media attachments (up to 4 images/videos per tweet)
+- **Quote Tweets** - Retweet with your own commentary
+- **Reply Threads** - Nested conversation threads
+- **Tweet Actions** - Like, retweet, quote, and reply
+- **Media Grid** - Responsive media display for images and videos
+
+### Direct Messaging
+
+- **Real-time Chat** - Instant messaging with Socket.IO
+- **Message Reactions** - React to messages with emojis
+- **Image Sharing** - Send and view images in conversations
+- **Message Deletion** - Delete sent messages
+- **Conversation List** - View all active conversations
+
+### Explore & Discovery
+
+- **For You** - Curated content discovery
+- **Trending Topics** - Popular hashtags and trends
+- **Category Sections** - Browse by category (News, Sports, Entertainment)
+- **User Search** - Find users by name or username
+- **Tweet Search** - Search content with filters
+
+### Search
+
+- **Advanced Filters** - Filter by users, tweets, hashtags
+- **Recent Searches** - Quick access to search history
+- **Real-time Results** - Instant search suggestions
+
+### Profile Management
+
+- **Profile Customization** - Edit display name, bio, location, website
+- **Profile Picture** - Upload and crop profile images
+- **Header Image** - Custom profile banner
+- **Following/Followers** - View and manage connections
+- **Profile Tabs** - View tweets, replies, media, and likes
+
+### Notifications
+
+- **Push Notifications** - Firebase Cloud Messaging integration
+- **In-App Notifications** - Real-time notification feed
+- **Notification Types** - Likes, retweets, mentions, follows, replies
+
+### Settings & Privacy
+
+- **Appearance** - Theme customization (light/dark mode)
+- **Account Settings** - Manage account information
+- **Privacy Controls**:
+ - Muted accounts management
+ - Blocked accounts management
+ - Content preferences
+ - Interest customization
+
+## Tech Stack
+
+### Core
+
+- **React Native** 0.81.5
+- **Expo** SDK 54
+- **TypeScript** 5.9
+
+### State Management
+
+- **Zustand** - Lightweight state management
+- **TanStack Query** (React Query) - Server state and caching
+
+### Styling
+
+- **NativeWind** 4 - TailwindCSS for React Native
+- **TailwindCSS** 3.4
+
+### Navigation
+
+- **React Navigation** 7 - Stack, Tab, Drawer, and Material Top Tabs
+
+### Real-time
+
+- **Socket.IO Client** - WebSocket-based real-time messaging
+- **React Native SSE** - Server-Sent Events for live updates
+
+### Media
+
+- **Expo Image** - High-performance image component
+- **Expo Image Picker** - Camera and gallery access
+- **Expo Video** - Video playback with Picture-in-Picture
+- **Expo Media Library** - Save and access device media
+
+### Testing
+
+- **Jest** - Unit testing framework
+- **Testing Library** - React Native testing utilities
+- **WebdriverIO** - End-to-end testing
+- **Appium** - Mobile automation
+- **MSW** - API mocking
+
+### Other Notable Libraries
+
+- **Shopify FlashList** - High-performance lists
+- **Lucide Icons** - Modern icon set
+- **React Native Gesture Handler** - Touch gestures
+- **React Native Popover View** - Contextual menus
+- **RN Emoji Keyboard** - Native emoji picker
+
+## Getting Started
+
+### Prerequisites
+
+- Node.js 18+
+- pnpm
+- Expo CLI
+- Android Studio (for Android development)
+- Xcode (for iOS development, macOS only)
+
+### Installation
+
+1. Clone the repository:
+
+ ```bash
+ git clone https://github.com/Exo1i/raven-cross-platform
+ cd raven
+ ```
+
+2. Install dependencies:
+
+ ```bash
+ pnpm install
+ ```
+
+3. Set up environment variables:
+
+ ```bash
+ cp .env.example .env
+ ```
+
+ Edit `.env` with your configuration values.
+
+4. Start the development server:
+
+ ```bash
+ pnpm start
+ ```
+
+### Running on Devices
+
+```bash
+# Android
+pnpm android
+
+# iOS
+pnpm ios
+```
+
+### Building
+
+Debug builds:
+
+```bash
+./build-debug.sh
+```
+
+Release builds:
+
+```bash
+./build-release.sh
+```
+
+## Scripts
+
+| Command | Description |
+| --------------- | ------------------------------ |
+| `pnpm start` | Start Expo development server |
+| `pnpm android` | Run on Android device/emulator |
+| `pnpm ios` | Run on iOS device/simulator |
+| `pnpm web` | Run in web browser |
+| `pnpm test` | Run tests in watch mode |
+| `pnpm test:cov` | Run tests with coverage |
+| `pnpm e2e` | Run end-to-end tests |
+
+## Configuration
+
+### EAS Build
+
+The project uses Expo Application Services (EAS) for building. Configuration is in `eas.json`:
+
+- **Development** - Debug builds for development
+- **Preview** - Internal testing builds
+- **Production** - Release builds for app stores
+
+### Firebase
+
+Push notifications require Firebase configuration:
+
+1. Create a Firebase project
+2. Add `google-services.json` for Android
+3. Set `GOOGLE_SERVICES_JSON` environment variable for CI/CD
+
+## Licenses
+
+### Fonts
+
+- **Inter** - The app uses the [Inter font family](https://fonts.google.com/specimen/Inter) by Rasmus Andersson, licensed under the [SIL Open Font License 1.1](https://scripts.sil.org/OFL).
+
+### Icons
+
+- **Lucide Icons** - Licensed under the [ISC License](https://github.com/lucide-icons/lucide/blob/main/LICENSE).
+- **Ionicons** - Licensed under the [MIT License](https://github.com/ionic-team/ionicons/blob/main/LICENSE).
+- **Expo Vector Icons** - A collection of icon sets, each with their respective licenses.
+
+### Images
+
+- App logo and splash screen images are original assets created for this project.
+- User-uploaded content is subject to the platform's terms of service.
+
+### Code Quality
+
+The project enforces code quality with:
+
+- **ESLint** - Linting with Expo and TypeScript rules
+- **Prettier** - Code formatting
+- **Husky** - Git hooks for pre-commit checks
+- **TypeScript** - Strict type checking
+
+## License
+
+This project is licensed under the [MIT License](LICENSE).
diff --git a/app.config.js b/app.config.js
index c7932c61b..3012281ee 100644
--- a/app.config.js
+++ b/app.config.js
@@ -39,7 +39,6 @@ export default ({ config }) => ({
plugins: [
'expo-secure-store',
- 'expo-localization',
[
'expo-media-library',
{
diff --git a/babel.config.js b/babel.config.js
index 7d507e111..657159fd6 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -2,5 +2,10 @@ module.exports = function (api) {
api.cache(true);
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
+ env: {
+ test: {
+ plugins: ['dynamic-import-node'],
+ },
+ },
};
};
diff --git a/e2e/pageobjects/Explore/ExploreScreen.page.js b/e2e/pageobjects/Explore/ExploreScreen.page.js
new file mode 100644
index 000000000..1ed0d6829
--- /dev/null
+++ b/e2e/pageobjects/Explore/ExploreScreen.page.js
@@ -0,0 +1,162 @@
+import { $, $$, driver } from '@wdio/globals';
+
+class ExploreScreen {
+ // Tab navigation
+ get forYouTab() {
+ return $('~ForYouTab');
+ }
+
+ get trendingTab() {
+ return $('~TrendingTab');
+ }
+
+ get newsTab() {
+ return $('~NewsTab');
+ }
+
+ get sportsTab() {
+ return $('~SportsTab');
+ }
+
+ get entertainmentTab() {
+ return $('~EntertainmentTab');
+ }
+
+ // Search trigger from Explore header
+ get searchTrigger() {
+ return $('~explore-search-trigger');
+ }
+
+ // Lists
+ get forYouFlashList() {
+ return $('~for-you-explore-flashlist');
+ }
+
+ get trendingList() {
+ return $('~trending-list');
+ }
+
+ get newsList() {
+ return $('~news-list');
+ }
+
+ // Bottom tab bar
+ get exploreTabBar() {
+ return $('~ExploreTab');
+ }
+
+ get homeTabBar() {
+ return $('~HomeTab');
+ }
+
+ // Helper methods
+ async switchToForYouTab() {
+ await this.forYouTab.waitForDisplayed();
+ await this.forYouTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToTrendingTab() {
+ await this.trendingTab.waitForDisplayed();
+ await this.trendingTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToNewsTab() {
+ await this.newsTab.waitForDisplayed();
+ await this.newsTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToSportsTab() {
+ await this.sportsTab.waitForDisplayed();
+ await this.sportsTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToEntertainmentTab() {
+ await this.entertainmentTab.waitForDisplayed();
+ await this.entertainmentTab.click();
+ await driver.pause(500);
+ }
+
+ async openSearch() {
+ await this.searchTrigger.waitForDisplayed();
+ await this.searchTrigger.click();
+ await driver.pause(500);
+ }
+
+ async goToHome() {
+ await this.homeTabBar.waitForDisplayed();
+ await this.homeTabBar.click();
+ await driver.pause(300);
+ }
+
+ async refresh() {
+ const element = await this.forYouFlashList;
+ await driver.execute('mobile: swipeGesture', {
+ elementId: element.elementId,
+ direction: 'down',
+ percent: 0.5,
+ });
+ await driver.pause(1500);
+ }
+
+ async getTrendCardByHashtag(hashtag) {
+ const normalizedHashtag = hashtag.replace('#', '');
+ return $(`~trend-card-${normalizedHashtag}`);
+ }
+
+ async clickTrendCard(hashtag) {
+ const card = await this.getTrendCardByHashtag(hashtag);
+ await card.waitForDisplayed();
+ await card.click();
+ await driver.pause(500);
+ }
+
+ async isForYouTabDisplayed() {
+ try {
+ return await this.forYouTab.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async isTrendingTabDisplayed() {
+ try {
+ return await this.trendingTab.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async waitForContentToLoad() {
+ // Simple wait for content to load
+ await driver.pause(1500);
+ }
+
+ async clickFirstTrendCard() {
+ try {
+ const trendCards = await $$('[accessibilityLabel^="trend-card-"]');
+ if (trendCards.length > 0) {
+ await trendCards[0].click();
+ await driver.pause(500);
+ return true;
+ }
+ } catch {
+ return false;
+ }
+ return false;
+ }
+
+ async isTrendCardDisplayed(hashtag) {
+ try {
+ const card = await this.getTrendCardByHashtag(hashtag);
+ return await card.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+}
+
+export default new ExploreScreen();
diff --git a/e2e/pageobjects/Home pages/ForYouScreen.page.js b/e2e/pageobjects/Home pages/ForYouScreen.page.js
deleted file mode 100644
index 13dfff81f..000000000
--- a/e2e/pageobjects/Home pages/ForYouScreen.page.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { $ } from '@wdio/globals';
-
-class ForYouScreen {
- get forYouTab() {
- return $('android=new UiSelector().text("For You")');
- }
- get followingTab() {
- return $('android=new UiSelector().text("Following")');
- }
- get sideMenuButton() {
- return $('~open-drawer-button');
- }
-
- async openSideMenu() {
- await this.sideMenuButton.waitForDisplayed();
- await this.sideMenuButton.click();
- }
-
- async switchToFollowingTab() {
- await this.followingTab.waitForDisplayed();
- await this.followingTab.click();
- }
-
- async isDisplayed() {
- return (await this.forYouTab.isDisplayed()) && (await this.followingTab.isDisplayed());
- }
-}
-
-export default new ForYouScreen();
diff --git a/e2e/pageobjects/Home/FollowingScreen.page.js b/e2e/pageobjects/Home/FollowingScreen.page.js
new file mode 100644
index 000000000..a2017ae35
--- /dev/null
+++ b/e2e/pageobjects/Home/FollowingScreen.page.js
@@ -0,0 +1,144 @@
+import { $, $$ } from '@wdio/globals';
+
+class FollowingScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Following")');
+ }
+
+ get sideMenuButton() {
+ return $('~open-drawer-button');
+ }
+
+ get newTweetButton() {
+ return $('~new-tweet-button');
+ }
+
+ get aiSummaryButton() {
+ return $$('~ai-summary-button');
+ }
+
+ get tweetReplyButton() {
+ return $$('~reply-button');
+ }
+
+ get tweetRetweetButton() {
+ return $$('~retweet-button');
+ }
+
+ get tweetLikeButton() {
+ return $$('~like-button');
+ }
+
+ get tweetLikeCount() {
+ return $$('~like-count');
+ }
+
+ get tweetReplyCount() {
+ return $$('~reply-count');
+ }
+
+ get tweetRetweetCount() {
+ return $$('~retweet-count');
+ }
+
+ get tweetDrawerButton() {
+ return $$('~tweet-drawer-button');
+ }
+
+ get tweetCard() {
+ return $$('~tweet-card');
+ }
+
+ get aiSummaryText() {
+ return $$('~ai-summary-text');
+ }
+
+ get tweetContent() {
+ return $$('~tweet-content');
+ }
+
+ get tweetMention() {
+ return $$('~tweet-mention');
+ }
+
+ get tweetHashtag() {
+ return $$('~tweet-hashtag');
+ }
+
+ get tweetLink() {
+ return $$('~tweet-link');
+ }
+
+ get tweetDeleteButton() {
+ return $('android=new UiSelector().text("Delete post")');
+ }
+
+ get deleteTweetModal() {
+ return $('android=new UiSelector().text("Delete post?")');
+ }
+
+ get confirmDeleteTweetButton() {
+ return $('~block-button');
+ }
+
+ get cancelDeleteTweetButton() {
+ return $('~cancel-block-button');
+ }
+
+ get repostFromDrawerButton() {
+ return $('android=new UiSelector().text("Repost")');
+ }
+
+ get quoteFromDrawerButton() {
+ return $('android=new UiSelector().text("Quote")');
+ }
+
+ get undoRepostFromDrawerButton() {
+ return $('android=new UiSelector().text("Undo Repost")');
+ }
+
+ async chooseRepostFromDrawer() {
+ await this.repostFromDrawerButton.waitForDisplayed();
+ await this.repostFromDrawerButton.click();
+ }
+
+ async chooseUndoRepostFromDrawer() {
+ await this.undoRepostFromDrawerButton.waitForDisplayed();
+ await this.undoRepostFromDrawerButton.click();
+ }
+
+ async chooseQuoteFromDrawer() {
+ await this.quoteFromDrawerButton.waitForDisplayed();
+ await this.quoteFromDrawerButton.click();
+ }
+
+ async clickNewTweetButton() {
+ await this.newTweetButton.waitForDisplayed();
+ await this.newTweetButton.click();
+ }
+
+ async deleteTweet() {
+ await this.tweetDeleteButton.waitForDisplayed();
+ await this.tweetDeleteButton.click();
+ }
+
+ async isDeleteTweetModalDisplayed() {
+ return this.deleteTweetModal.isDisplayed();
+ }
+
+ async confirmDeleteTweet() {
+ await this.confirmDeleteTweetButton.waitForDisplayed();
+ await this.confirmDeleteTweetButton.click();
+ }
+
+ async cancelDeleteTweet() {
+ await this.cancelDeleteTweetButton.waitForDisplayed();
+ await this.cancelDeleteTweetButton.click();
+ }
+
+ async isDisplayed() {
+ return this.screenTitle.isDisplayed();
+ }
+}
+
+export default new FollowingScreen();
diff --git a/e2e/pageobjects/Home/ForYouScreen.page.js b/e2e/pageobjects/Home/ForYouScreen.page.js
new file mode 100644
index 000000000..834255cf1
--- /dev/null
+++ b/e2e/pageobjects/Home/ForYouScreen.page.js
@@ -0,0 +1,61 @@
+import { $ } from '@wdio/globals';
+
+class ForYouScreen {
+ get forYouTab() {
+ return $('android=new UiSelector().text("For You")');
+ }
+ get followingTab() {
+ return $('android=new UiSelector().text("Following")');
+ }
+ get sideMenuButton() {
+ return $('~open-drawer-button');
+ }
+ get notificationsTab() {
+ return $('~NotificationsTab');
+ }
+ get messagingTab() {
+ return $('~MessagesTab');
+ }
+ get exploreTab() {
+ return $('~ExploreTab');
+ }
+ get newTweetButton() {
+ return $('~new-tweet-button');
+ }
+
+ async switchToNotificationsTab() {
+ await this.notificationsTab.waitForDisplayed();
+ await this.notificationsTab.click();
+ }
+
+ async switchToMessagingTab() {
+ await this.messagingTab.waitForDisplayed();
+ await this.messagingTab.click();
+ }
+
+ async openSideMenu() {
+ await this.sideMenuButton.waitForDisplayed();
+ await this.sideMenuButton.click();
+ }
+
+ async switchToFollowingTab() {
+ await this.followingTab.waitForDisplayed();
+ await this.followingTab.click();
+ }
+
+ async switchToExploreTab() {
+ await this.exploreTab.waitForDisplayed();
+ await this.exploreTab.click();
+ }
+
+ async isDisplayed() {
+ return (await this.forYouTab.isDisplayed()) && (await this.followingTab.isDisplayed());
+ }
+
+ async clickNewTweetButton() {
+ await this.newTweetButton.waitForDisplayed();
+ await this.newTweetButton.click();
+ }
+}
+
+export default new ForYouScreen();
diff --git a/e2e/pageobjects/Home pages/SidebarScreen.page.js b/e2e/pageobjects/Home/SidebarScreen.page.js
similarity index 51%
rename from e2e/pageobjects/Home pages/SidebarScreen.page.js
rename to e2e/pageobjects/Home/SidebarScreen.page.js
index 58771ae72..cee6a1ca2 100644
--- a/e2e/pageobjects/Home pages/SidebarScreen.page.js
+++ b/e2e/pageobjects/Home/SidebarScreen.page.js
@@ -1,4 +1,4 @@
-import { $ } from '@wdio/globals';
+import { $, driver } from '@wdio/globals';
class SidebarScreen {
get logoutButton() {
@@ -25,6 +25,55 @@ class SidebarScreen {
get currentUsername() {
return $('~sidebar-current-username');
}
+ get followingCount() {
+ return $('~sidebar-following-count');
+ }
+ get followersCount() {
+ return $('~sidebar-followers-count');
+ }
+ get themeSwitcherButton() {
+ return $('~theme-switcher-drawer');
+ }
+ get darkThemeButton() {
+ return $('android=new UiSelector().text("On")');
+ }
+ get lightThemeButton() {
+ return $('android=new UiSelector().text("Off")');
+ }
+ get systemThemeButton() {
+ return $('android=new UiSelector().text("Use device settings")');
+ }
+
+ async openThemeSwitcher() {
+ await this.themeSwitcherButton.waitForDisplayed();
+ await this.themeSwitcherButton.click();
+ }
+
+ async chooseDarkTheme() {
+ await this.darkThemeButton.waitForDisplayed();
+ await this.darkThemeButton.click();
+ }
+
+ async chooseLightTheme() {
+ await this.lightThemeButton.waitForDisplayed();
+ await this.lightThemeButton.click();
+ }
+
+ async chooseSystemTheme() {
+ await this.systemThemeButton.waitForDisplayed();
+ await this.systemThemeButton.click();
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+ get followingButton() {
+ return $('~sidebar-following-button');
+ }
+ get followersButton() {
+ return $('~sidebar-followers-button');
+ }
async logout() {
await this.logoutButton.waitForDisplayed();
@@ -51,6 +100,23 @@ class SidebarScreen {
await this.profileButton.click();
}
+ async goToChat() {
+ await this.chatButton.waitForDisplayed();
+ await this.chatButton.click();
+ }
+
+ async goToFollowing() {
+ await this.followingButton.waitForDisplayed();
+ await this.followingButton.click();
+ await driver.pause(500);
+ }
+
+ async goToFollowers() {
+ await this.followersButton.waitForDisplayed();
+ await this.followersButton.click();
+ await driver.pause(500);
+ }
+
async isDisplayed() {
return (
(await this.logoutButton.isDisplayed()) &&
diff --git a/e2e/pageobjects/Login pages/LoginEmail.page.js b/e2e/pageobjects/Login/LoginEmail.page.js
similarity index 100%
rename from e2e/pageobjects/Login pages/LoginEmail.page.js
rename to e2e/pageobjects/Login/LoginEmail.page.js
diff --git a/e2e/pageobjects/Login pages/LoginPassword.page.js b/e2e/pageobjects/Login/LoginPassword.page.js
similarity index 100%
rename from e2e/pageobjects/Login pages/LoginPassword.page.js
rename to e2e/pageobjects/Login/LoginPassword.page.js
diff --git a/e2e/pageobjects/Messages/ChatScreen.page.js b/e2e/pageobjects/Messages/ChatScreen.page.js
new file mode 100644
index 000000000..f78777199
--- /dev/null
+++ b/e2e/pageobjects/Messages/ChatScreen.page.js
@@ -0,0 +1,111 @@
+import { $, $$, browser, driver } from '@wdio/globals';
+
+class ChatScreen {
+ get noMessagesText() {
+ return $('android=new UiSelector().text("No messages yet. Start the conversation.")');
+ }
+
+ get messageInput() {
+ return $('~chat-input');
+ }
+
+ get sendMessageButton() {
+ return $('~send-message-button');
+ }
+
+ get scrollBottomButton() {
+ return $('~scroll-bottom-chat');
+ }
+
+ get messageContent() {
+ return $$('~message-content');
+ }
+
+ get messageSent() {
+ return $('android=new UiSelector().textContains("Sent")');
+ }
+
+ async isNoMessagesYetDisplayed() {
+ return this.noMessagesText.isDisplayed();
+ }
+
+ async isCorrectUsernameDisplayed(username) {
+ const usernameSelector = $(`android=new UiSelector().text("${username}")`);
+ await usernameSelector.waitForDisplayed();
+ return usernameSelector.isDisplayed();
+ }
+
+ async isCorrectDisplayNameDisplayed(displayName) {
+ const displayNameSelector = $(`android=new UiSelector().text("${displayName}")`);
+ await displayNameSelector.waitForDisplayed();
+ return displayNameSelector.isDisplayed();
+ }
+
+ async typeMessage(message) {
+ await this.messageInput.waitForDisplayed();
+ await this.messageInput.setValue(message);
+ }
+
+ async sendMessage() {
+ await this.sendMessageButton.waitForDisplayed();
+ await this.sendMessageButton.click();
+ }
+
+ async scrollToBottom() {
+ await this.scrollBottomButton.waitForDisplayed();
+ await this.scrollBottomButton.click();
+ }
+
+ async isScrollBottomDisplayed() {
+ return this.scrollBottomButton.isDisplayed();
+ }
+
+ async isMessageDisplayed(message) {
+ const messageSelector = $(`android=new UiSelector().text("${message}")`);
+ await messageSelector.waitForDisplayed();
+ return messageSelector.isDisplayed();
+ }
+
+ async isMessageSentDisplayed() {
+ await this.messageSent.waitForDisplayed();
+ return this.messageSent.isDisplayed();
+ }
+
+ async goToUserProfile(displayName) {
+ const displayNameSelector = $(`android=new UiSelector().text("${displayName}")`);
+ await displayNameSelector.waitForDisplayed();
+ await displayNameSelector.click();
+ }
+
+ async scrollUp() {
+ const { width, height } = await browser.getWindowRect();
+
+ const anchorX = width * 0.5;
+ const startY = height * 0.2;
+ const endY = height * 0.7;
+
+ await browser.performActions([
+ {
+ type: 'pointer',
+ id: 'finger1',
+ parameters: { pointerType: 'touch' },
+ actions: [
+ { type: 'pointerMove', duration: 0, x: anchorX, y: startY },
+ { type: 'pointerDown', button: 0 },
+ { type: 'pause', duration: 100 },
+ { type: 'pointerMove', duration: 800, x: anchorX, y: endY },
+ { type: 'pointerUp', button: 0 },
+ ],
+ },
+ ]);
+
+ await browser.pause(800);
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+}
+
+export default new ChatScreen();
diff --git a/e2e/pageobjects/Messages/MessagesScreen.page.js b/e2e/pageobjects/Messages/MessagesScreen.page.js
new file mode 100644
index 000000000..8b0374f1b
--- /dev/null
+++ b/e2e/pageobjects/Messages/MessagesScreen.page.js
@@ -0,0 +1,117 @@
+import { $, $$, browser, driver } from '@wdio/globals';
+
+class MessagesScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Messages")');
+ }
+
+ get noConvosMessage() {
+ return $('android=new UiSelector().text("No conversations")');
+ }
+
+ get newMessageButton() {
+ return $('~new-message-button');
+ }
+
+ get cancelNewMessageButton() {
+ return $('android=new UiSelector().text("Cancel")');
+ }
+
+ get newMessageSearchInput() {
+ return $('~new-message-search-input');
+ }
+
+ get newMessageBackdrop() {
+ return $('~new-message-backdrop');
+ }
+
+ get newMessageRecipients() {
+ return $$('~new-message-recipient');
+ }
+
+ get recipientDisplayNames() {
+ return $$('~recipient-display-name');
+ }
+
+ get recipientUsernames() {
+ return $$('~recipient-username');
+ }
+
+ get sideMenuButton() {
+ return $('~open-drawer-button');
+ }
+
+ get noUsersFound() {
+ return $('android=new UiSelector().text("No users found")');
+ }
+
+ get conversations() {
+ return $$('~conversation-list-item');
+ }
+
+ get conversationUsernames() {
+ return $$('~conversation-username');
+ }
+
+ get conversationPreviews() {
+ return $$('~conversation-preview');
+ }
+
+ get conversationUnreadIndicator() {
+ return $$('~conversation-unread');
+ }
+
+ get badgeCount() {
+ return $('~badge-count');
+ }
+
+ async openSideMenu() {
+ await this.sideMenuButton.waitForDisplayed();
+ await this.sideMenuButton.click();
+ }
+
+ async clickNewMessageButton() {
+ await this.newMessageButton.waitForDisplayed();
+ await this.newMessageButton.click();
+ }
+
+ async cancelNewMessage() {
+ await this.cancelNewMessageButton.waitForDisplayed();
+ await this.cancelNewMessageButton.click();
+ }
+
+ async searchFor(username) {
+ await this.newMessageSearchInput.waitForDisplayed();
+ await this.newMessageSearchInput.setValue(username);
+ }
+
+ async refresh() {
+ await browser.swipe({
+ direction: 'down',
+ percent: 1, // 100%
+ });
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+
+ async isNewMessageDrawerDisplayed() {
+ return (
+ this.newMessageBackdrop.isDisplayed() &&
+ this.newMessageSearchInput.isDisplayed() &&
+ this.cancelNewMessageButton.isDisplayed()
+ );
+ }
+
+ async isNoUsersFoundDisplayed() {
+ return this.noUsersFound.waitForDisplayed();
+ }
+
+ async isNoConversationsMessageDisplayed() {
+ return this.noConvosMessage.isDisplayed();
+ }
+}
+
+export default new MessagesScreen();
diff --git a/e2e/pageobjects/Notifications/AllNotificationsScreen.page.js b/e2e/pageobjects/Notifications/AllNotificationsScreen.page.js
new file mode 100644
index 000000000..b0da20342
--- /dev/null
+++ b/e2e/pageobjects/Notifications/AllNotificationsScreen.page.js
@@ -0,0 +1,102 @@
+import { $, $$, browser } from '@wdio/globals';
+
+class AllNotificationsScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Notifications")');
+ }
+
+ get replyNotifications() {
+ return $$('android=new UiSelector().textContains("replied")');
+ }
+
+ get quoteNotifications() {
+ return $$('android=new UiSelector().textContains("quoted")');
+ }
+
+ get retweetNotifications() {
+ return $$('android=new UiSelector().textContains("retweeted")');
+ }
+
+ get likeNotifications() {
+ return $$('android=new UiSelector().textContains("liked")');
+ }
+
+ get mentionNotifications() {
+ return $$('android=new UiSelector().textContains("mentioned")');
+ }
+
+ get followNotifications() {
+ return $$('android=new UiSelector().textContains("followed")');
+ }
+
+ get mentionsTab() {
+ return $('android=new UiSelector().text("Mentions")');
+ }
+
+ get noNotificationsMessage() {
+ return $('android=new UiSelector().text("You\'re all caught up")');
+ }
+
+ get followBackButton() {
+ return $$('android=new UiSelector().text("Follow back")');
+ }
+
+ get followingButton() {
+ return $$('android=new UiSelector().text("Following")');
+ }
+
+ get followNotifUserDisplayName() {
+ return $$('~follow-notification-user-displayname');
+ }
+
+ get replyIcon() {
+ return $$('~notification-reply-button');
+ }
+
+ get retweetIcon() {
+ return $$('~notification-retweet-button');
+ }
+
+ get likeIcon() {
+ return $$('~notification-like-button');
+ }
+
+ get replyCount() {
+ return $$('~notification-reply-count');
+ }
+
+ get retweetCount() {
+ return $$('~notification-retweet-count');
+ }
+
+ get likeCount() {
+ return $$('~notification-like-count');
+ }
+
+ get homeIcon() {
+ return $('~HomeTab');
+ }
+
+ async isNoNotificationsMessageDisplayed() {
+ return await this.noNotificationsMessage.isDisplayed();
+ }
+
+ async goToMentionsTab() {
+ await this.mentionsTab.waitForDisplayed();
+ await this.mentionsTab.click();
+ }
+
+ async refresh() {
+ await browser.swipe({
+ direction: 'down',
+ percent: 1, // 100%
+ });
+ }
+
+ async goToForYouScreen() {
+ await this.homeIcon.waitForDisplayed();
+ await this.homeIcon.click();
+ }
+}
+
+export default new AllNotificationsScreen();
diff --git a/e2e/pageobjects/Notifications/MentionsScreen.page.js b/e2e/pageobjects/Notifications/MentionsScreen.page.js
new file mode 100644
index 000000000..41dd31da3
--- /dev/null
+++ b/e2e/pageobjects/Notifications/MentionsScreen.page.js
@@ -0,0 +1,52 @@
+import { $, $$, browser } from '@wdio/globals';
+
+class MentionsScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Notifications")');
+ }
+
+ get noNotificationsMessage() {
+ return $('android=new UiSelector().text("You\'re all caught up")');
+ }
+
+ get mentionNotifications() {
+ return $$('android=new UiSelector().textContains("mentioned")');
+ }
+
+ get replyIcon() {
+ return $$('~notification-reply-button');
+ }
+
+ get retweetIcon() {
+ return $$('~notification-retweet-button');
+ }
+
+ get likeIcon() {
+ return $$('~notification-like-button');
+ }
+
+ get replyCount() {
+ return $$('~notification-reply-count');
+ }
+
+ get retweetCount() {
+ return $$('~notification-retweet-count');
+ }
+
+ get likeCount() {
+ return $$('~notification-like-count');
+ }
+
+ async isNoNotificationsMessageDisplayed() {
+ return await this.noNotificationsMessage.isDisplayed();
+ }
+
+ async refresh() {
+ await browser.swipe({
+ direction: 'down',
+ percent: 1, // 100%
+ });
+ }
+}
+
+export default new MentionsScreen();
diff --git a/e2e/pageobjects/Home pages/OnboardingBioScreen.page.js b/e2e/pageobjects/Onboarding/OnboardingBioScreen.page.js
similarity index 100%
rename from e2e/pageobjects/Home pages/OnboardingBioScreen.page.js
rename to e2e/pageobjects/Onboarding/OnboardingBioScreen.page.js
diff --git a/e2e/pageobjects/Onboarding/OnboardingFollowScreen.page.js b/e2e/pageobjects/Onboarding/OnboardingFollowScreen.page.js
new file mode 100644
index 000000000..546794574
--- /dev/null
+++ b/e2e/pageobjects/Onboarding/OnboardingFollowScreen.page.js
@@ -0,0 +1,34 @@
+import { $, $$ } from '@wdio/globals';
+
+class OnboardingFollowScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Follow 1 or more accounts")');
+ }
+
+ get interactionButtons() {
+ return $$('~onboarding-follow-button');
+ }
+
+ get nextButton() {
+ return $('android=new UiSelector().text("Next")');
+ }
+
+ get followButtons() {
+ return $$('android=new UiSelector().text("Follow")');
+ }
+
+ get followingButtons() {
+ return $$('android=new UiSelector().text("Following")');
+ }
+
+ async isNextButtonEnabled() {
+ return await this.nextButton.isEnabled();
+ }
+
+ async clickNext() {
+ await this.nextButton.waitForDisplayed();
+ await this.nextButton.click();
+ }
+}
+
+export default new OnboardingFollowScreen();
diff --git a/e2e/pageobjects/Onboarding/OnboardingInterestsScreen.page.js b/e2e/pageobjects/Onboarding/OnboardingInterestsScreen.page.js
new file mode 100644
index 000000000..b1fb2b96e
--- /dev/null
+++ b/e2e/pageobjects/Onboarding/OnboardingInterestsScreen.page.js
@@ -0,0 +1,48 @@
+import { $, driver } from '@wdio/globals';
+
+class OnboardingInterestsScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("What do you want to see on Raven?")');
+ }
+
+ get nextButton() {
+ return $('android=new UiSelector().text("Next")');
+ }
+
+ get noneSelectedMessage() {
+ return $('android=new UiSelector().text("0 of 1 selected")');
+ }
+
+ async clickNext() {
+ await this.nextButton.waitForDisplayed();
+ await this.nextButton.click();
+ }
+
+ async clickInterests(interests) {
+ for (const interest of interests) {
+ const interestElement = await $(`android=new UiSelector().text("${interest}")`);
+ await interestElement.waitForDisplayed();
+ await interestElement.click();
+ }
+ }
+
+ async isDisplayed() {
+ return (await this.screenTitle.isDisplayed()) && (await this.nextButton.isDisplayed());
+ }
+
+ async isNextButtonEnabled() {
+ await this.nextButton.waitForDisplayed();
+ return this.nextButton.isEnabled();
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+
+ async isNoneSelectedMessageDisplayed() {
+ return this.noneSelectedMessage.isDisplayed();
+ }
+}
+
+export default new OnboardingInterestsScreen();
diff --git a/e2e/pageobjects/Home pages/OnboardingProfilePicScreen.page.js b/e2e/pageobjects/Onboarding/OnboardingProfilePicScreen.page.js
similarity index 100%
rename from e2e/pageobjects/Home pages/OnboardingProfilePicScreen.page.js
rename to e2e/pageobjects/Onboarding/OnboardingProfilePicScreen.page.js
diff --git a/e2e/pageobjects/Home pages/OnboardingUsernameScreen.page.js b/e2e/pageobjects/Onboarding/OnboardingUsernameScreen.page.js
similarity index 100%
rename from e2e/pageobjects/Home pages/OnboardingUsernameScreen.page.js
rename to e2e/pageobjects/Onboarding/OnboardingUsernameScreen.page.js
diff --git a/e2e/pageobjects/Profile pages/ProfileScreen.page.js b/e2e/pageobjects/Profile pages/ProfileScreen.page.js
deleted file mode 100644
index 96c0b4e14..000000000
--- a/e2e/pageobjects/Profile pages/ProfileScreen.page.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import { $ } from '@wdio/globals';
-
-class ProfileScreen {
- get editProfileButton() {
- return $('android=new UiSelector().text("Edit profile")');
- }
-
- get setupProfileButton() {
- return $('android=new UiSelector().text("Set up profile")');
- }
-
- get currentButton() {
- return $('~edit-profile-button');
- }
-
- get displayName() {
- return $('~profile-display-name');
- }
-
- get bio() {
- return $('~profile-bio');
- }
-
- get location() {
- return $('~profile-location');
- }
-
- get website() {
- return $('~profile-website');
- }
-
- get birthDate() {
- return $('~profile-birthdate');
- }
-
- async isSetupProfileButtonDisplayed() {
- return this.setupProfileButton.isDisplayed();
- }
-
- async isEditProfileButtonDisplayed() {
- return this.editProfileButton.isDisplayed();
- }
-
- async goToSetupProfile() {
- await this.setupProfileButton.waitForDisplayed();
- await this.setupProfileButton.click();
- }
-
- async goToEditProfile() {
- await this.editProfileButton.waitForDisplayed();
- await this.editProfileButton.click();
- }
-
- async isDisplayed() {
- return (
- (await this.displayName.isDisplayed()) &&
- (await this.bio.isDisplayed()) &&
- (await this.location.isDisplayed()) &&
- (await this.website.isDisplayed()) &&
- (await this.birthDate.isDisplayed())
- );
- }
-}
-
-export default new ProfileScreen();
diff --git a/e2e/pageobjects/Profile/ConnectionsScreen.page.js b/e2e/pageobjects/Profile/ConnectionsScreen.page.js
new file mode 100644
index 000000000..52ccc353e
--- /dev/null
+++ b/e2e/pageobjects/Profile/ConnectionsScreen.page.js
@@ -0,0 +1,170 @@
+import { $, $$, driver } from '@wdio/globals';
+
+class ConnectionsScreen {
+ // Tab navigation
+ get followingTab() {
+ return $('android=new UiSelector().text("Following")');
+ }
+
+ get followersTab() {
+ return $('android=new UiSelector().text("Followers")');
+ }
+
+ get followersYouKnowTab() {
+ return $('android=new UiSelector().text("Followers you know")');
+ }
+
+ // Connection list items
+ get connectionItems() {
+ return $$(
+ 'android=new UiSelector().className("android.view.ViewGroup").descriptionContains("Avatar")'
+ );
+ }
+
+ // Follow/Unfollow buttons in list
+ get followButtons() {
+ return $$('~connection-follow-button');
+ }
+
+ get followingButtons() {
+ return $$('~connection-following-button');
+ }
+
+ // First user in list selectors
+ get firstConnectionFollowButton() {
+ return $('~connection-follow-button');
+ }
+
+ get firstConnectionFollowingButton() {
+ return $('~connection-following-button');
+ }
+
+ // Empty state
+ get emptyStateMessage() {
+ return $('android=new UiSelector().textContains("Looking for")');
+ }
+
+ // Loading
+ get loadingSpinner() {
+ return $('~footer-spinner');
+ }
+
+ // Back button
+ get backButton() {
+ return $('android=new UiSelector().className("android.widget.ImageView")');
+ }
+
+ // Helper methods
+ async switchToFollowingTab() {
+ await this.followingTab.waitForDisplayed();
+ await this.followingTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToFollowersTab() {
+ await this.followersTab.waitForDisplayed();
+ await this.followersTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToFollowersYouKnowTab() {
+ await this.followersYouKnowTab.waitForDisplayed();
+ await this.followersYouKnowTab.click();
+ await driver.pause(500);
+ }
+
+ async isFollowingTabDisplayed() {
+ try {
+ return await this.followingTab.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async isFollowersTabDisplayed() {
+ try {
+ return await this.followersTab.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async isFollowersYouKnowTabDisplayed() {
+ try {
+ return await this.followersYouKnowTab.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async clickFirstFollowButton() {
+ await this.firstConnectionFollowButton.waitForDisplayed();
+ await this.firstConnectionFollowButton.click();
+ await driver.pause(500);
+ }
+
+ async clickFirstFollowingButton() {
+ await this.firstConnectionFollowingButton.waitForDisplayed();
+ await this.firstConnectionFollowingButton.click();
+ await driver.pause(500);
+ }
+
+ async hasConnectionInList(displayName) {
+ const selector = `android=new UiSelector().textContains("${displayName}")`;
+ try {
+ const element = await $(selector);
+ return await element.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async clickOnConnectionByDisplayName(displayName) {
+ const selector = `android=new UiSelector().textContains("${displayName}")`;
+ const element = await $(selector);
+ await element.waitForDisplayed();
+ await element.click();
+ await driver.pause(500);
+ }
+
+ async getFollowButtonCount() {
+ try {
+ const buttons = await this.followButtons;
+ return buttons.length;
+ } catch {
+ return 0;
+ }
+ }
+
+ async getFollowingButtonCount() {
+ try {
+ const buttons = await this.followingButtons;
+ return buttons.length;
+ } catch {
+ return 0;
+ }
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+
+ async pullToRefresh() {
+ // Perform a swipe down to refresh
+ const { width, height } = await driver.getWindowSize();
+ const startX = width / 2;
+ const startY = height * 0.3;
+ const endY = height * 0.7;
+
+ await driver.touchAction([
+ { action: 'press', x: startX, y: startY },
+ { action: 'wait', ms: 500 },
+ { action: 'moveTo', x: startX, y: endY },
+ { action: 'release' },
+ ]);
+ await driver.pause(1000);
+ }
+}
+
+export default new ConnectionsScreen();
diff --git a/e2e/pageobjects/Profile pages/EditProfileScreen.page.js b/e2e/pageobjects/Profile/EditProfileScreen.page.js
similarity index 100%
rename from e2e/pageobjects/Profile pages/EditProfileScreen.page.js
rename to e2e/pageobjects/Profile/EditProfileScreen.page.js
diff --git a/e2e/pageobjects/Profile/ProfileScreen.page.js b/e2e/pageobjects/Profile/ProfileScreen.page.js
new file mode 100644
index 000000000..147617666
--- /dev/null
+++ b/e2e/pageobjects/Profile/ProfileScreen.page.js
@@ -0,0 +1,400 @@
+import { $, $$, browser, driver } from '@wdio/globals';
+
+class ProfileScreen {
+ // Profile action buttons
+ get editProfileButton() {
+ return $('android=new UiSelector().text("Edit profile")');
+ }
+
+ get setupProfileButton() {
+ return $('android=new UiSelector().text("Set up profile")');
+ }
+
+ get followingButton() {
+ return $('android=new UiSelector().text("Following")');
+ }
+
+ get followBackButton() {
+ return $('android=new UiSelector().text("Follow back")');
+ }
+
+ get followButton() {
+ return $('android=new UiSelector().text("Follow")');
+ }
+
+ get blockedButton() {
+ return $('android=new UiSelector().text("Blocked")');
+ }
+
+ get currentButton() {
+ return $('~edit-profile-button');
+ }
+
+ // Profile info
+ get displayName() {
+ return $('~profile-display-name');
+ }
+
+ get username() {
+ return $('~profile-username');
+ }
+
+ get bio() {
+ return $('~profile-bio');
+ }
+
+ get location() {
+ return $('~profile-location');
+ }
+
+ get website() {
+ return $('~profile-website');
+ }
+
+ get birthDate() {
+ return $('~profile-birthdate');
+ }
+
+ // Banner and navigation
+ get backButton() {
+ return $('~back-button');
+ }
+
+ get menuDropdownButton() {
+ return $('~menu-dropdown-button');
+ }
+
+ get menuDropdown() {
+ return $('~menu-dropdown');
+ }
+
+ // Dropdown menu options
+ get shareButton() {
+ return $('~share-button');
+ }
+
+ get muteButton() {
+ return $('~mute-button');
+ }
+
+ get unmuteButton() {
+ return $('~unmute-button');
+ }
+
+ get blockButtonMenu() {
+ return $('~block-button-menu');
+ }
+
+ get unblockButton() {
+ return $('~unblock-button');
+ }
+
+ // Modals
+ get blockConfirmButton() {
+ return $('~block-button');
+ }
+
+ get cancelBlockButton() {
+ return $('~cancel-block-button');
+ }
+
+ get unfollowConfirmButton() {
+ return $('~unfollow-confirm-button');
+ }
+
+ // Muted indicator on profile
+ get mutedIndicator() {
+ return $('android=new UiSelector().textContains("You have muted posts from this account")');
+ }
+
+ get unmuteLinkInIndicator() {
+ return $('android=new UiSelector().text("Unmute")');
+ }
+
+ // Blocked state - view posts
+ get viewPostsButton() {
+ return $('android=new UiSelector().text("View Posts")');
+ }
+
+ get blockedMessage() {
+ return $('android=new UiSelector().textContains("is blocked")');
+ }
+
+ // Message button
+ get messageButton() {
+ return $('~profile-message-button');
+ }
+
+ // Profile stats
+ get followersCount() {
+ return $('android=new UiSelector().textContains("Followers")');
+ }
+
+ get followingCount() {
+ return $('android=new UiSelector().textContains("Following")');
+ }
+
+ get followersStatButton() {
+ return $('~profile-followers-stat');
+ }
+
+ get followingStatButton() {
+ return $('~profile-following-stat');
+ }
+
+ // Profile content tabs
+ get postsTab() {
+ return $('~ProfilePostsTab');
+ }
+
+ get repliesTab() {
+ return $('~ProfileRepliesTab');
+ }
+
+ get mediaTab() {
+ return $('~ProfileMediaTab');
+ }
+
+ get likesTab() {
+ return $('~ProfileLikesTab');
+ }
+
+ // Tweet content
+ get tweetCards() {
+ return $$('~tweet-card');
+ }
+
+ // Helper methods
+ async isSetupProfileButtonDisplayed() {
+ return this.setupProfileButton.isDisplayed();
+ }
+
+ async isEditProfileButtonDisplayed() {
+ return this.editProfileButton.isDisplayed();
+ }
+
+ async isFollowingButtonDisplayed() {
+ return this.followingButton.isDisplayed();
+ }
+
+ async isFollowBackButtonDisplayed() {
+ return this.followBackButton.isDisplayed();
+ }
+
+ async isFollowButtonDisplayed() {
+ return this.followButton.isDisplayed();
+ }
+
+ async isBlockedButtonDisplayed() {
+ return this.blockedButton.isDisplayed();
+ }
+
+ async isMutedIndicatorDisplayed() {
+ return this.mutedIndicator.isDisplayed();
+ }
+
+ async isBlockedMessageDisplayed() {
+ return this.blockedMessage.isDisplayed();
+ }
+
+ async isMessageButtonDisplayed() {
+ try {
+ return await this.messageButton.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async goToSetupProfile() {
+ await this.setupProfileButton.waitForDisplayed();
+ await this.setupProfileButton.click();
+ }
+
+ async goToEditProfile() {
+ await this.editProfileButton.waitForDisplayed();
+ await this.editProfileButton.click();
+ }
+
+ async openMenuDropdown() {
+ await this.menuDropdownButton.waitForDisplayed();
+ await this.menuDropdownButton.click();
+ await driver.pause(300);
+ }
+
+ async closeMenuDropdown() {
+ const { width, height } = await browser.getWindowRect();
+ const anchorX = width * 0.5;
+ let from = { x: anchorX, y: height * 0.5 };
+ let to = { x: anchorX, y: height * 0.1 };
+ let speed = 1000;
+ await browser.performActions([
+ {
+ type: 'pointer',
+ id: 'finger1',
+ parameters: { pointerType: 'touch' },
+ actions: [
+ { type: 'pointerMove', duration: 0, x: from.x, y: from.y },
+ { type: 'pointerDown', button: 0 },
+ { type: 'pause', duration: 100 },
+ { type: 'pointerMove', duration: speed, x: to.x, y: to.y },
+ { type: 'pointerUp', button: 0 },
+ ],
+ },
+ ]);
+ await browser.pause(500);
+ }
+
+ async muteUser() {
+ await this.muteButton.waitForDisplayed();
+ await this.muteButton.click();
+ await this.blockConfirmButton.waitForDisplayed();
+ await this.blockConfirmButton.click();
+ await driver.pause(500);
+ }
+
+ async unmuteUser() {
+ await this.unmuteButton.waitForDisplayed();
+ await this.unmuteButton.click();
+ await this.blockConfirmButton.waitForDisplayed();
+ await this.blockConfirmButton.click();
+ await driver.pause(500);
+ }
+
+ async unmuteFromIndicator() {
+ await this.unmuteLinkInIndicator.waitForDisplayed();
+ await this.unmuteLinkInIndicator.click();
+ await this.blockConfirmButton.waitForDisplayed();
+ await this.blockConfirmButton.click();
+ await driver.pause(500);
+ }
+
+ async blockUser() {
+ await this.blockButtonMenu.waitForDisplayed();
+ await this.blockButtonMenu.click();
+ await this.blockConfirmButton.waitForDisplayed();
+ await this.blockConfirmButton.click();
+ await driver.pause(500);
+ }
+
+ async unblockUser() {
+ await this.unblockButton.waitForDisplayed();
+ await this.unblockButton.click();
+ await this.blockConfirmButton.waitForDisplayed();
+ await this.blockConfirmButton.click();
+ await driver.pause(500);
+ }
+
+ async unblockFromButton() {
+ await this.blockedButton.waitForDisplayed();
+ await this.blockedButton.click();
+ await this.blockConfirmButton.waitForDisplayed();
+ await this.blockConfirmButton.click();
+ await driver.pause(500);
+ }
+
+ async followUser() {
+ await this.followButton.waitForDisplayed();
+ await this.followButton.click();
+ await driver.pause(500);
+ }
+
+ async followBackUser() {
+ await this.followBackButton.waitForDisplayed();
+ await this.followBackButton.click();
+ await driver.pause(500);
+ }
+
+ async unfollowUser() {
+ await this.followingButton.waitForDisplayed();
+ await this.followingButton.click();
+ await this.unfollowConfirmButton.waitForDisplayed();
+ await this.unfollowConfirmButton.click();
+ await driver.pause(500);
+ }
+
+ async clickOnMessageButton() {
+ await this.messageButton.waitForDisplayed();
+ await this.messageButton.click();
+ }
+
+ async viewBlockedUserPosts() {
+ await this.viewPostsButton.waitForDisplayed();
+ await this.viewPostsButton.click();
+ }
+
+ async isDisplayed() {
+ return (
+ (await this.displayName.isDisplayed()) &&
+ (await this.bio.isDisplayed()) &&
+ (await this.location.isDisplayed()) &&
+ (await this.website.isDisplayed()) &&
+ (await this.birthDate.isDisplayed())
+ );
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+
+ async goToFollowers() {
+ await this.followersStatButton.waitForDisplayed();
+ await this.followersStatButton.click();
+ await driver.pause(500);
+ }
+
+ async goToFollowing() {
+ await this.followingStatButton.waitForDisplayed();
+ await this.followingStatButton.click();
+ await driver.pause(500);
+ }
+
+ async switchToPostsTab() {
+ await this.postsTab.waitForDisplayed();
+ await this.postsTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToRepliesTab() {
+ await this.repliesTab.waitForDisplayed();
+ await this.repliesTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToMediaTab() {
+ await this.mediaTab.waitForDisplayed();
+ await this.mediaTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToLikesTab() {
+ await this.likesTab.waitForDisplayed();
+ await this.likesTab.click();
+ await driver.pause(500);
+ }
+
+ async refresh() {
+ const { width, height } = await browser.getWindowRect();
+ const anchorX = width * 0.5;
+ let from = { x: anchorX, y: height * 0.7 };
+ let to = { x: anchorX, y: height * 0.9 };
+ let speed = 1000;
+ await browser.performActions([
+ {
+ type: 'pointer',
+ id: 'finger1',
+ parameters: { pointerType: 'touch' },
+ actions: [
+ { type: 'pointerMove', duration: 0, x: from.x, y: from.y },
+ { type: 'pointerDown', button: 0 },
+ { type: 'pause', duration: 100 },
+ { type: 'pointerMove', duration: speed, x: to.x, y: to.y },
+ { type: 'pointerUp', button: 0 },
+ ],
+ },
+ ]);
+ await browser.pause(500);
+ }
+}
+
+export default new ProfileScreen();
diff --git a/e2e/pageobjects/Reset Password pages/ConfirmCodeResetPasswordScreen.page.js b/e2e/pageobjects/Reset Password/ConfirmCodeResetPasswordScreen.page.js
similarity index 100%
rename from e2e/pageobjects/Reset Password pages/ConfirmCodeResetPasswordScreen.page.js
rename to e2e/pageobjects/Reset Password/ConfirmCodeResetPasswordScreen.page.js
diff --git a/e2e/pageobjects/Reset Password pages/ConfirmMethodResetPasswordScreen.page.js b/e2e/pageobjects/Reset Password/ConfirmMethodResetPasswordScreen.page.js
similarity index 100%
rename from e2e/pageobjects/Reset Password pages/ConfirmMethodResetPasswordScreen.page.js
rename to e2e/pageobjects/Reset Password/ConfirmMethodResetPasswordScreen.page.js
diff --git a/e2e/pageobjects/Reset Password pages/EmailResetPasswordScreen.page.js b/e2e/pageobjects/Reset Password/EmailResetPasswordScreen.page.js
similarity index 100%
rename from e2e/pageobjects/Reset Password pages/EmailResetPasswordScreen.page.js
rename to e2e/pageobjects/Reset Password/EmailResetPasswordScreen.page.js
diff --git a/e2e/pageobjects/Reset Password pages/NewPasswordResetPasswordScreen.page.js b/e2e/pageobjects/Reset Password/NewPasswordResetPasswordScreen.page.js
similarity index 100%
rename from e2e/pageobjects/Reset Password pages/NewPasswordResetPasswordScreen.page.js
rename to e2e/pageobjects/Reset Password/NewPasswordResetPasswordScreen.page.js
diff --git a/e2e/pageobjects/Search/SearchFiltersScreen.page.js b/e2e/pageobjects/Search/SearchFiltersScreen.page.js
new file mode 100644
index 000000000..c9cfee07e
--- /dev/null
+++ b/e2e/pageobjects/Search/SearchFiltersScreen.page.js
@@ -0,0 +1,102 @@
+import { $, driver } from '@wdio/globals';
+
+class SearchFiltersScreen {
+ // Screen container
+ get screen() {
+ return $('~search-filters-screen');
+ }
+
+ // Header buttons
+ get cancelButton() {
+ return $('~search-filters-cancel');
+ }
+
+ get applyButton() {
+ return $('~search-filters-apply');
+ }
+
+ // People filter section
+ get peopleFilterSection() {
+ return $('~people-filter-section');
+ }
+
+ get fromAnyoneRadio() {
+ return $('~radio-from-anyone');
+ }
+
+ get peopleYouFollowRadio() {
+ return $('~radio-people-you-follow');
+ }
+
+ // Safety toggle section
+ get safetyFilterSection() {
+ return $('~safety-filter-section');
+ }
+
+ get removeBlockedMutedToggle() {
+ return $('~toggle-remove-blocked-and-muted-accounts');
+ }
+
+ // Helper methods
+ async waitForScreen() {
+ await this.screen.waitForDisplayed();
+ await driver.pause(300);
+ }
+
+ async cancel() {
+ await this.cancelButton.waitForDisplayed();
+ await this.cancelButton.click();
+ await driver.pause(300);
+ }
+
+ async apply() {
+ await this.applyButton.waitForDisplayed();
+ await this.applyButton.click();
+ await driver.pause(500);
+ }
+
+ async selectFromAnyone() {
+ await this.fromAnyoneRadio.waitForDisplayed();
+ await this.fromAnyoneRadio.click();
+ await driver.pause(200);
+ }
+
+ async selectPeopleYouFollow() {
+ await this.peopleYouFollowRadio.waitForDisplayed();
+ await this.peopleYouFollowRadio.click();
+ await driver.pause(200);
+ }
+
+ async toggleRemoveBlockedMuted() {
+ await this.removeBlockedMutedToggle.waitForDisplayed();
+ await this.removeBlockedMutedToggle.click();
+ await driver.pause(200);
+ }
+
+ async isFromAnyoneSelected() {
+ try {
+ const radioInner = await $('~radio-from-anyone').$('~radio-inner');
+ return await radioInner.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async isPeopleYouFollowSelected() {
+ try {
+ const radioInner = await $('~radio-people-you-follow').$('~radio-inner');
+ return await radioInner.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async isApplyButtonEnabled() {
+ const button = await this.applyButton;
+ // Check if button has opacity-40 class (disabled state)
+ // In appium, we check if the element is enabled
+ return await button.isEnabled();
+ }
+}
+
+export default new SearchFiltersScreen();
diff --git a/e2e/pageobjects/Search/SearchScreen.page.js b/e2e/pageobjects/Search/SearchScreen.page.js
new file mode 100644
index 000000000..cbaa28b60
--- /dev/null
+++ b/e2e/pageobjects/Search/SearchScreen.page.js
@@ -0,0 +1,277 @@
+import { $, $$, driver } from '@wdio/globals';
+
+class SearchScreen {
+ // Search input
+ get searchInput() {
+ return $('~search-input');
+ }
+
+ get clearSearchButton() {
+ return $('~clear-search');
+ }
+
+ get cancelSearchButton() {
+ return $('~cancel-search');
+ }
+
+ get backButton() {
+ return $('~search-back');
+ }
+
+ get settingsButton() {
+ return $('~search-gear');
+ }
+
+ // Tabs
+ get topTab() {
+ return $('android=new UiSelector().text("Top")');
+ }
+
+ get latestTab() {
+ return $('android=new UiSelector().text("Latest")');
+ }
+
+ get peopleTab() {
+ return $('android=new UiSelector().text("People")');
+ }
+
+ get mediaTab() {
+ return $('android=new UiSelector().text("Media")');
+ }
+
+ // Results
+ get noResultsMessage() {
+ return $('android=new UiSelector().textContains("No results")');
+ }
+
+ // Suggestions
+ get suggestionsEmpty() {
+ return $('~search-suggestions-empty');
+ }
+
+ get suggestionSearchAction() {
+ return $('~suggestion-search-action');
+ }
+
+ get suggestionGotoAction() {
+ return $('~suggestion-goto-action');
+ }
+
+ // Top People
+ get topPeopleSection() {
+ return $('~search-top-people');
+ }
+
+ get topPeopleViewAll() {
+ return $('~search-top-people-view-all');
+ }
+
+ // People Results
+ get peopleResultsList() {
+ return $('~search-people-results');
+ }
+
+ get emptyState() {
+ return $('~search-empty-state');
+ }
+
+ // Search Input Overlay
+ get searchInputOverlay() {
+ return $('~search-input-overlay');
+ }
+
+ // Navigation triggers
+ get exploreSearchTrigger() {
+ return $('~explore-search-trigger');
+ }
+
+ get homeIcon() {
+ return $('~HomeTab');
+ }
+
+ // Helper methods
+ async openFromExplore() {
+ await this.exploreSearchTrigger.waitForDisplayed();
+ await this.exploreSearchTrigger.click();
+ await driver.pause(500);
+ }
+
+ async searchFor(query) {
+ await this.searchInput.waitForDisplayed();
+ await this.searchInput.setValue(query);
+ await driver.pressKeyCode(66); // Enter key
+ await driver.pause(1000);
+ }
+
+ async clearSearch() {
+ await this.clearSearchButton.waitForDisplayed();
+ await this.clearSearchButton.click();
+ await driver.pause(300);
+ }
+
+ async cancelSearch() {
+ await this.cancelSearchButton.waitForDisplayed();
+ await this.cancelSearchButton.click();
+ await driver.pause(300);
+ }
+
+ async goBack() {
+ await this.backButton.waitForDisplayed();
+ await this.backButton.click();
+ await driver.pause(300);
+ }
+
+ async switchToTopTab() {
+ await this.topTab.waitForDisplayed();
+ await this.topTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToLatestTab() {
+ await this.latestTab.waitForDisplayed();
+ await this.latestTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToPeopleTab() {
+ await this.peopleTab.waitForDisplayed();
+ await this.peopleTab.click();
+ await driver.pause(500);
+ }
+
+ async switchToMediaTab() {
+ await this.mediaTab.waitForDisplayed();
+ await this.mediaTab.click();
+ await driver.pause(500);
+ }
+
+ async isTopTabDisplayed() {
+ try {
+ return await this.topTab.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async isPeopleTabDisplayed() {
+ try {
+ return await this.peopleTab.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async hasUserInResults(displayName) {
+ const selector = `android=new UiSelector().textContains("${displayName}")`;
+ try {
+ const element = await $$(selector);
+ return await element[1].isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async clickOnUserInResults(displayName) {
+ const selector = `android=new UiSelector().textContains("${displayName}")`;
+ const element = await $$(selector);
+ await element[1].waitForDisplayed();
+ await element[1].click();
+ await driver.pause(500);
+ }
+
+ async goToForYouScreen() {
+ await this.homeIcon.waitForDisplayed();
+ await this.homeIcon.click();
+ }
+
+ async isSuggestionsEmptyDisplayed() {
+ try {
+ return await this.suggestionsEmpty.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async isTopPeopleSectionDisplayed() {
+ try {
+ return await this.topPeopleSection.isDisplayed();
+ } catch {
+ return false;
+ }
+ }
+
+ async clickTopPeopleViewAll() {
+ try {
+ if (await this.topPeopleViewAll.isDisplayed()) {
+ await this.topPeopleViewAll.click();
+ await driver.pause(500);
+ return true;
+ }
+ } catch {
+ return false;
+ }
+ return false;
+ }
+
+ async isPeopleResultsDisplayed() {
+ try {
+ return await this.peopleResultsList.waitForDisplayed({ timeout: 5000 });
+ } catch {
+ return false;
+ }
+ }
+
+ async isEmptyStateDisplayed() {
+ try {
+ return await this.emptyState.waitForDisplayed({ timeout: 5000 });
+ } catch {
+ return false;
+ }
+ }
+
+ async isSuggestionSearchActionDisplayed() {
+ try {
+ return await this.suggestionSearchAction.waitForDisplayed({ timeout: 3000 });
+ } catch {
+ return false;
+ }
+ }
+
+ async clickSuggestionSearchAction() {
+ try {
+ if (await this.suggestionSearchAction.isDisplayed()) {
+ await this.suggestionSearchAction.click();
+ await driver.pause(500);
+ return true;
+ }
+ } catch {
+ return false;
+ }
+ return false;
+ }
+
+ async clickSearchInputOverlay() {
+ try {
+ if (await this.searchInputOverlay.isDisplayed()) {
+ await this.searchInputOverlay.click();
+ await driver.pause(300);
+ return true;
+ }
+ } catch {
+ return false;
+ }
+ return false;
+ }
+
+ async typeQuery(query) {
+ await this.searchInput.setValue(query);
+ await driver.pause(500);
+ }
+
+ async submitSearch() {
+ await driver.pressKeyCode(66);
+ await driver.pause(1000);
+ }
+}
+
+export default new SearchScreen();
diff --git a/e2e/pageobjects/Settings pages/AccountInformationScreen.page.js b/e2e/pageobjects/Settings/AccountInformationScreen.page.js
similarity index 74%
rename from e2e/pageobjects/Settings pages/AccountInformationScreen.page.js
rename to e2e/pageobjects/Settings/AccountInformationScreen.page.js
index 82204e481..62441786c 100644
--- a/e2e/pageobjects/Settings pages/AccountInformationScreen.page.js
+++ b/e2e/pageobjects/Settings/AccountInformationScreen.page.js
@@ -17,6 +17,14 @@ class AccountInformationScreen {
return $('android=new UiSelector().text("Password")');
}
+ get changeCountryButton() {
+ return $('android=new UiSelector().text("Country")');
+ }
+
+ get selectedCountry() {
+ return $('~account-info-current-country');
+ }
+
get logoutButton() {
return $('android=new UiSelector().text("Log out")');
}
@@ -25,6 +33,10 @@ class AccountInformationScreen {
return $('~account-info-username-header');
}
+ get email() {
+ return $('~account-info-current-email');
+ }
+
async logout() {
await this.logoutButton.waitForDisplayed();
await this.logoutButton.click();
@@ -47,12 +59,19 @@ class AccountInformationScreen {
await this.changePasswordButton.click();
}
+ async goToChangeCountry() {
+ await this.changeCountryButton.waitForDisplayed();
+ await this.changeCountryButton.waitForEnabled();
+ await this.changeCountryButton.click();
+ }
+
async isDisplayed() {
return (
this.screenTitle.isDisplayed() &&
this.changeUsernameButton.isDisplayed() &&
this.changeEmailButton.isDisplayed() &&
- this.changePasswordButton.isDisplayed()
+ this.changePasswordButton.isDisplayed() &&
+ this.changeCountryButton.isDisplayed()
);
}
}
diff --git a/e2e/pageobjects/Settings pages/AccountSettingsScreen.page.js b/e2e/pageobjects/Settings/AccountSettingsScreen.page.js
similarity index 71%
rename from e2e/pageobjects/Settings pages/AccountSettingsScreen.page.js
rename to e2e/pageobjects/Settings/AccountSettingsScreen.page.js
index 0e8d200da..b168854de 100644
--- a/e2e/pageobjects/Settings pages/AccountSettingsScreen.page.js
+++ b/e2e/pageobjects/Settings/AccountSettingsScreen.page.js
@@ -9,11 +9,20 @@ class AccountSettingsScreen {
return $('android=new UiSelector().text("Account information")');
}
+ get changePasswordButton() {
+ return $('android=new UiSelector().text("Change password")');
+ }
+
async goToAccountInformation() {
await this.accountInformationButton.waitForDisplayed();
await this.accountInformationButton.click();
}
+ async goToChangePassword() {
+ await this.changePasswordButton.waitForDisplayed();
+ await this.changePasswordButton.click();
+ }
+
async isDisplayed() {
return (
(await this.screenTitle.isDisplayed()) && (await this.accountInformationButton.isDisplayed())
diff --git a/e2e/pageobjects/Settings/AppearanceSettingsScreen.page.js b/e2e/pageobjects/Settings/AppearanceSettingsScreen.page.js
new file mode 100644
index 000000000..678d84259
--- /dev/null
+++ b/e2e/pageobjects/Settings/AppearanceSettingsScreen.page.js
@@ -0,0 +1,41 @@
+import { $, driver } from '@wdio/globals';
+
+class AppearanceSettingsScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Theme")');
+ }
+
+ get lightThemeButton() {
+ return $('android=new UiSelector().text("Light")');
+ }
+
+ get darkThemeButton() {
+ return $('android=new UiSelector().text("Dark")');
+ }
+
+ get systemThemeButton() {
+ return $('android=new UiSelector().text("System")');
+ }
+
+ async chooseDarkTheme() {
+ await this.darkThemeButton.waitForDisplayed();
+ await this.darkThemeButton.click();
+ }
+
+ async chooseLightTheme() {
+ await this.lightThemeButton.waitForDisplayed();
+ await this.lightThemeButton.click();
+ }
+
+ async chooseSystemTheme() {
+ await this.systemThemeButton.waitForDisplayed();
+ await this.systemThemeButton.click();
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+}
+
+export default new AppearanceSettingsScreen();
diff --git a/e2e/pageobjects/Settings/BlockedListScreen.page.js b/e2e/pageobjects/Settings/BlockedListScreen.page.js
new file mode 100644
index 000000000..4447d3e1f
--- /dev/null
+++ b/e2e/pageobjects/Settings/BlockedListScreen.page.js
@@ -0,0 +1,48 @@
+import { $, $$, browser, driver } from '@wdio/globals';
+
+class BlockedListScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Blocked accounts")');
+ }
+
+ get noBlockedAccounts() {
+ return $('android=new UiSelector().text("Block unwanted accounts")');
+ }
+
+ get blockedAccountsList() {
+ return $$('~connection-list-item');
+ }
+
+ get blockedButton() {
+ return $('android=new UiSelector().text("Blocked")');
+ }
+
+ get unblockConfirmButton() {
+ return $('~block-button');
+ }
+
+ async isNoBlockedAccountsDisplayed() {
+ return await this.noBlockedAccounts.isDisplayed();
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+
+ async refresh() {
+ await browser.swipe({
+ direction: 'down',
+ percent: 1, // 100%
+ });
+ }
+
+ async unblockFirstUser() {
+ await this.blockedButton.click();
+ await this.unblockConfirmButton.waitForDisplayed();
+ await this.unblockConfirmButton.click();
+ await driver.pause(500);
+ }
+}
+
+export default new BlockedListScreen();
diff --git a/e2e/pageobjects/Settings/ChangeCountryScreen.page.js b/e2e/pageobjects/Settings/ChangeCountryScreen.page.js
new file mode 100644
index 000000000..8fd241f3c
--- /dev/null
+++ b/e2e/pageobjects/Settings/ChangeCountryScreen.page.js
@@ -0,0 +1,68 @@
+import { $, browser, driver } from '@wdio/globals';
+
+class ChangeCountryScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Country")');
+ }
+
+ get countrySearchInput() {
+ return $('~country-search-input');
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+
+ async swipeUp() {
+ const { width, height } = await browser.getWindowRect();
+ const anchorX = width * 0.5;
+ let from = { x: anchorX, y: height * 0.6 };
+ let to = { x: anchorX, y: height * 0.1 };
+ let speed = 1000;
+ await browser.performActions([
+ {
+ type: 'pointer',
+ id: 'finger1',
+ parameters: { pointerType: 'touch' },
+ actions: [
+ { type: 'pointerMove', duration: 0, x: from.x, y: from.y },
+ { type: 'pointerDown', button: 0 },
+ { type: 'pause', duration: 50 },
+ { type: 'pointerMove', duration: speed, x: to.x, y: to.y },
+ { type: 'pointerUp', button: 0 },
+ ],
+ },
+ ]);
+ await browser.pause(500);
+ }
+
+ async clickCountry(countryName) {
+ const countryElement = await $(`android=new UiSelector().text("${countryName}")`);
+ await countryElement.click();
+ }
+
+ async findCountry(countryName) {
+ const countryElement = await $(`android=new UiSelector().text("${countryName}")`);
+ while (!(await countryElement.isDisplayed())) {
+ await this.swipeUp();
+ }
+ }
+
+ async searchForCountry(countryName) {
+ await this.countrySearchInput.waitForDisplayed();
+ await this.countrySearchInput.setValue(countryName);
+ await this.countrySearchInput.setValue('');
+ }
+
+ async isCountryDisplayed(countryName) {
+ const countryElement = await $(`android=new UiSelector().text("${countryName}")`);
+ return await countryElement.isDisplayed();
+ }
+
+ async isDisplayed() {
+ return (await this.screenTitle.isDisplayed()) && (await this.countrySearchInput.isDisplayed());
+ }
+}
+
+export default new ChangeCountryScreen();
diff --git a/e2e/pageobjects/Settings pages/ChangeEmailScreen.page.js b/e2e/pageobjects/Settings/ChangeEmailScreen.page.js
similarity index 100%
rename from e2e/pageobjects/Settings pages/ChangeEmailScreen.page.js
rename to e2e/pageobjects/Settings/ChangeEmailScreen.page.js
diff --git a/e2e/pageobjects/Settings pages/ChangePasswordScreen.page.js b/e2e/pageobjects/Settings/ChangePasswordScreen.page.js
similarity index 95%
rename from e2e/pageobjects/Settings pages/ChangePasswordScreen.page.js
rename to e2e/pageobjects/Settings/ChangePasswordScreen.page.js
index bf4638808..2f97258fb 100644
--- a/e2e/pageobjects/Settings pages/ChangePasswordScreen.page.js
+++ b/e2e/pageobjects/Settings/ChangePasswordScreen.page.js
@@ -1,4 +1,4 @@
-import { $ } from '@wdio/globals';
+import { $, driver } from '@wdio/globals';
class ChangePasswordScreen {
get screenTitle() {
@@ -82,6 +82,11 @@ class ChangePasswordScreen {
async isIncorrectOldPasswordErrorMessageDisplayed() {
return await this.incorrectOldPasswordErrorMessage.isDisplayed();
}
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
}
export default new ChangePasswordScreen();
diff --git a/e2e/pageobjects/Settings pages/ChangeUsernameScreen.page.js b/e2e/pageobjects/Settings/ChangeUsernameScreen.page.js
similarity index 100%
rename from e2e/pageobjects/Settings pages/ChangeUsernameScreen.page.js
rename to e2e/pageobjects/Settings/ChangeUsernameScreen.page.js
diff --git a/e2e/pageobjects/Settings/ConfirmChangeCountryScreen.page.js b/e2e/pageobjects/Settings/ConfirmChangeCountryScreen.page.js
new file mode 100644
index 000000000..c6540d432
--- /dev/null
+++ b/e2e/pageobjects/Settings/ConfirmChangeCountryScreen.page.js
@@ -0,0 +1,47 @@
+import { $, driver } from '@wdio/globals';
+
+class ConfirmChangeCountryScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().textContains("Change country")');
+ }
+
+ get changeButton() {
+ return $('android=new UiSelector().text("Change")');
+ }
+
+ get cancelButton() {
+ return $('android=new UiSelector().text("Cancel")');
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+
+ async isConfirmMessageDisplayed(countryName) {
+ const confirmMessage = await $(
+ `android=new UiSelector().textContains("Change country to ${countryName}?")`
+ );
+ return confirmMessage.isDisplayed();
+ }
+
+ async isDisplayed() {
+ return (
+ (await this.screenTitle.isDisplayed()) &&
+ (await this.changeButton.isDisplayed()) &&
+ (await this.cancelButton.isDisplayed())
+ );
+ }
+
+ async clickChange() {
+ await this.changeButton.waitForEnabled();
+ await this.changeButton.click();
+ }
+
+ async clickCancel() {
+ await this.cancelButton.waitForDisplayed();
+ await this.cancelButton.click();
+ }
+}
+
+export default new ConfirmChangeCountryScreen();
diff --git a/e2e/pageobjects/Settings/ContentYouSeeScreen.page.js b/e2e/pageobjects/Settings/ContentYouSeeScreen.page.js
new file mode 100644
index 000000000..29c887361
--- /dev/null
+++ b/e2e/pageobjects/Settings/ContentYouSeeScreen.page.js
@@ -0,0 +1,18 @@
+import { $ } from '@wdio/globals';
+
+class ContentYouSeeScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Content you see")');
+ }
+
+ get interests() {
+ return $('android=new UiSelector().text("Interests")');
+ }
+
+ async goToInterests() {
+ await this.interests.waitForDisplayed();
+ await this.interests.click();
+ }
+}
+
+export default new ContentYouSeeScreen();
diff --git a/e2e/pageobjects/Settings/InterestsScreen.page.js b/e2e/pageobjects/Settings/InterestsScreen.page.js
new file mode 100644
index 000000000..7fe5e50ee
--- /dev/null
+++ b/e2e/pageobjects/Settings/InterestsScreen.page.js
@@ -0,0 +1,31 @@
+import { $, driver } from '@wdio/globals';
+
+class InterestsScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Interests")');
+ }
+
+ get saveButton() {
+ return $('~interests-save-changes');
+ }
+
+ async selectInterests(interests) {
+ for (let interest of interests) {
+ const interestElement = $(`android=new UiSelector().text("${interest}")`);
+ await interestElement.waitForDisplayed();
+ await interestElement.click();
+ await driver.pause(500);
+ }
+ }
+
+ async isSaveButtonEnabled() {
+ return await this.saveButton.isEnabled();
+ }
+
+ async saveChanges() {
+ await this.saveButton.waitForDisplayed();
+ await this.saveButton.click();
+ }
+}
+
+export default new InterestsScreen();
diff --git a/e2e/pageobjects/Settings/MuteAndBlockScreen.page.js b/e2e/pageobjects/Settings/MuteAndBlockScreen.page.js
new file mode 100644
index 000000000..a55ae06ef
--- /dev/null
+++ b/e2e/pageobjects/Settings/MuteAndBlockScreen.page.js
@@ -0,0 +1,32 @@
+import { $, driver } from '@wdio/globals';
+
+class MuteAndBlockScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Mute and block")');
+ }
+
+ get blockedAccountsList() {
+ return $('android=new UiSelector().text("Blocked accounts")');
+ }
+
+ get mutedAccountsList() {
+ return $('android=new UiSelector().text("Muted accounts")');
+ }
+
+ async goToBlockedList() {
+ await this.blockedAccountsList.waitForDisplayed();
+ await this.blockedAccountsList.click();
+ }
+
+ async goToMutedList() {
+ await this.mutedAccountsList.waitForDisplayed();
+ await this.mutedAccountsList.click();
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+}
+
+export default new MuteAndBlockScreen();
diff --git a/e2e/pageobjects/Settings/MutedListScreen.page.js b/e2e/pageobjects/Settings/MutedListScreen.page.js
new file mode 100644
index 000000000..2e19220af
--- /dev/null
+++ b/e2e/pageobjects/Settings/MutedListScreen.page.js
@@ -0,0 +1,42 @@
+import { $, $$, browser, driver } from '@wdio/globals';
+
+class MutedListScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Muted accounts")');
+ }
+
+ get noMutedAccounts() {
+ return $('android=new UiSelector().text("Muted accounts")');
+ }
+
+ get mutedAccountsList() {
+ return $$('~connection-list-item');
+ }
+
+ get muteButton() {
+ return $('~mute-button-muted');
+ }
+
+ async isNoMutedAccountsDisplayed() {
+ return await this.noMutedAccounts.isDisplayed();
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+
+ async refresh() {
+ await browser.swipe({
+ direction: 'down',
+ percent: 1, // 100%
+ });
+ }
+
+ async unmuteFirstUser() {
+ await this.muteButton.click();
+ await driver.pause(500);
+ }
+}
+
+export default new MutedListScreen();
diff --git a/e2e/pageobjects/Settings/PrivacySettingsScreen.page.js b/e2e/pageobjects/Settings/PrivacySettingsScreen.page.js
new file mode 100644
index 000000000..2f9579388
--- /dev/null
+++ b/e2e/pageobjects/Settings/PrivacySettingsScreen.page.js
@@ -0,0 +1,27 @@
+import { $ } from '@wdio/globals';
+
+class PrivacySettingsScreen {
+ get screenTitle() {
+ return $('android=new UiSelector().text("Privacy and safety")');
+ }
+
+ get muteAndBlockButton() {
+ return $('android=new UiSelector().text("Mute and block")');
+ }
+
+ get contentYouSeeButton() {
+ return $('android=new UiSelector().text("Content you see")');
+ }
+
+ async goToMuteAndBlock() {
+ await this.muteAndBlockButton.waitForDisplayed();
+ await this.muteAndBlockButton.click();
+ }
+
+ async goToContentYouSee() {
+ await this.contentYouSeeButton.waitForDisplayed();
+ await this.contentYouSeeButton.click();
+ }
+}
+
+export default new PrivacySettingsScreen();
diff --git a/e2e/pageobjects/Settings pages/SettingsScreen.page.js b/e2e/pageobjects/Settings/SettingsScreen.page.js
similarity index 78%
rename from e2e/pageobjects/Settings pages/SettingsScreen.page.js
rename to e2e/pageobjects/Settings/SettingsScreen.page.js
index a9eb65fe1..6fbb4eb8c 100644
--- a/e2e/pageobjects/Settings pages/SettingsScreen.page.js
+++ b/e2e/pageobjects/Settings/SettingsScreen.page.js
@@ -26,6 +26,16 @@ class SettingsScreen {
await this.accountSettingsButton.click();
}
+ async goToPrivacySettings() {
+ await this.privacySettingsButton.waitForDisplayed();
+ await this.privacySettingsButton.click();
+ }
+
+ async goToAppearanceSettings() {
+ await this.appearanceSettingsButton.waitForDisplayed();
+ await this.appearanceSettingsButton.click();
+ }
+
async isDisplayed() {
return (
(await this.settingsScreenTitle.isDisplayed()) &&
diff --git a/e2e/pageobjects/SignUp pages/SignupStep1.page.js b/e2e/pageobjects/SignUp/SignupStep1.page.js
similarity index 100%
rename from e2e/pageobjects/SignUp pages/SignupStep1.page.js
rename to e2e/pageobjects/SignUp/SignupStep1.page.js
diff --git a/e2e/pageobjects/SignUp pages/SignupStep2.page.js b/e2e/pageobjects/SignUp/SignupStep2.page.js
similarity index 100%
rename from e2e/pageobjects/SignUp pages/SignupStep2.page.js
rename to e2e/pageobjects/SignUp/SignupStep2.page.js
diff --git a/e2e/pageobjects/SignUp pages/SignupStep3.page.js b/e2e/pageobjects/SignUp/SignupStep3.page.js
similarity index 100%
rename from e2e/pageobjects/SignUp pages/SignupStep3.page.js
rename to e2e/pageobjects/SignUp/SignupStep3.page.js
diff --git a/e2e/pageobjects/Tweets/TweetComposerScreen.page.js b/e2e/pageobjects/Tweets/TweetComposerScreen.page.js
new file mode 100644
index 000000000..aee5122a6
--- /dev/null
+++ b/e2e/pageobjects/Tweets/TweetComposerScreen.page.js
@@ -0,0 +1,70 @@
+import { $, $$, driver } from '@wdio/globals';
+
+class TweetComposerScreen {
+ get postButton() {
+ return $('~composer-post-button');
+ }
+
+ get tweetInput() {
+ return $('~tweet-text-input');
+ }
+
+ get composerMediaImages() {
+ return $$('~composer-media-image');
+ }
+
+ get composerRemoveMedia() {
+ return $$('~composer-remove-media');
+ }
+
+ get composerDiscardModal() {
+ return $('android=new UiSelector().text("Discard Tweet?")');
+ }
+
+ get composerDiscardModalDiscardButton() {
+ return $('android=new UiSelector().text("DISCARD")');
+ }
+
+ get composerDiscardModalCancelButton() {
+ return $('android=new UiSelector().text("CANCEL")');
+ }
+
+ async writeTweet(tweet) {
+ await this.tweetInput.waitForDisplayed();
+ await this.tweetInput.setValue(tweet);
+ }
+
+ async postTweet() {
+ await this.postButton.waitForDisplayed();
+ await this.postButton.click();
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+
+ async isDisplayed() {
+ return (await this.postButton.isDisplayed()) && (await this.tweetInput.isDisplayed());
+ }
+
+ async isPostButtonEnabled() {
+ return await this.postButton.isEnabled();
+ }
+
+ async isDiscardModalDisplayed() {
+ return await this.composerDiscardModal.isDisplayed();
+ }
+
+ async discardTweet() {
+ await this.composerDiscardModalDiscardButton.waitForDisplayed();
+ await this.composerDiscardModalDiscardButton.click();
+ }
+
+ async cancelDiscardTweet() {
+ await this.composerDiscardModalCancelButton.waitForDisplayed();
+ await this.composerDiscardModalCancelButton.click();
+ }
+}
+
+export default new TweetComposerScreen();
diff --git a/e2e/pageobjects/Tweets/TweetDetailsScreen.page.js b/e2e/pageobjects/Tweets/TweetDetailsScreen.page.js
new file mode 100644
index 000000000..43d23796d
--- /dev/null
+++ b/e2e/pageobjects/Tweets/TweetDetailsScreen.page.js
@@ -0,0 +1,187 @@
+import { $, $$, driver } from '@wdio/globals';
+
+class TweetDetailsScreen {
+ // Screen identification
+ get screenTitle() {
+ return $('~tweet-view');
+ }
+
+ // Main tweet container
+ get mainTweetContainer() {
+ return $('~main-tweet-container');
+ }
+
+ // Tweet content
+ get tweetContent() {
+ return $$('~tweet-content');
+ }
+
+ get mainTweetContent() {
+ return this.mainTweetContainer.$('~tweet-content');
+ }
+
+ // Reply containers
+ get replyContainers() {
+ return $$('~reply-tweet-container');
+ }
+
+ // Parent tweet containers (thread context)
+ get parentContainers() {
+ return $$('~parent-tweet-container');
+ }
+
+ // Thread navigation
+ get showMoreButton() {
+ return $('~thread-show-more-button');
+ }
+
+ // Reply input elements
+ get replyInput() {
+ return $('~reply-input');
+ }
+
+ get submitReplyButton() {
+ return $('~submit-reply');
+ }
+
+ get replyingToContainer() {
+ return $('~replying-to-container');
+ }
+
+ // Tweet action buttons (on main tweet)
+ get likeButton() {
+ return $('~like-button');
+ }
+
+ get retweetButton() {
+ return $('~retweet-button');
+ }
+
+ get replyButton() {
+ return $('~reply-button');
+ }
+
+ get shareButton() {
+ return $('~share-button');
+ }
+
+ // Tweet counts
+ get likeCount() {
+ return $('~like-count');
+ }
+
+ get retweetCount() {
+ return $('~retweet-count');
+ }
+
+ get replyCount() {
+ return $('~reply-count');
+ }
+
+ // Tweet drawer
+ get tweetDrawerButton() {
+ return $('~tweet-drawer-button');
+ }
+
+ // Repost/Quote drawer options
+ get repostOption() {
+ return $('~repost-option');
+ }
+
+ get quoteOption() {
+ return $('~quote-option');
+ }
+
+ // AI Summary
+ get aiSummaryButton() {
+ return $('~ai-summary-button');
+ }
+
+ get aiSummaryText() {
+ return $('~ai-summary-text');
+ }
+
+ // All tweet cards on this screen
+ get tweetCards() {
+ return $$('~tweet-card');
+ }
+
+ // Methods
+ async isDisplayed() {
+ return this.screenTitle.isDisplayed();
+ }
+
+ async goBack() {
+ await driver.back();
+ await driver.pause(500);
+ }
+
+ async typeReply(text) {
+ await this.replyInput.waitForDisplayed();
+ await this.replyInput.click();
+ await this.replyInput.setValue(text);
+ }
+
+ async submitReply() {
+ await this.submitReplyButton.waitForDisplayed();
+ await this.submitReplyButton.click();
+ }
+
+ async replyToTweet(text) {
+ await this.typeReply(text);
+ await this.submitReply();
+ }
+
+ async toggleLike() {
+ await this.likeButton.waitForDisplayed();
+ await this.likeButton.click();
+ }
+
+ async openRetweetMenu() {
+ await this.retweetButton.waitForDisplayed();
+ await this.retweetButton.click();
+ }
+
+ async repost() {
+ await this.openRetweetMenu();
+ await this.repostOption.waitForDisplayed();
+ await this.repostOption.click();
+ }
+
+ async openQuoteComposer() {
+ await this.openRetweetMenu();
+ await this.quoteOption.waitForDisplayed();
+ await this.quoteOption.click();
+ }
+
+ async clickShowMore() {
+ await this.showMoreButton.waitForDisplayed();
+ await this.showMoreButton.click();
+ }
+
+ async getReplyCount() {
+ const replies = await this.replyContainers;
+ return replies.length;
+ }
+
+ async isMainTweetDisplayed() {
+ return this.mainTweetContainer.isDisplayed();
+ }
+
+ async getLikeCountValue() {
+ const count = await this.likeCount.getText();
+ return parseInt(count) || 0;
+ }
+
+ async getRetweetCountValue() {
+ const count = await this.retweetCount.getText();
+ return parseInt(count) || 0;
+ }
+
+ async getReplyCountValue() {
+ const count = await this.replyCount.getText();
+ return parseInt(count) || 0;
+ }
+}
+
+export default new TweetDetailsScreen();
diff --git a/e2e/specs/accountInfoSettings.e2e.js b/e2e/specs/accountInfoSettings.e2e.js
index 046189eb9..3fe69e302 100644
--- a/e2e/specs/accountInfoSettings.e2e.js
+++ b/e2e/specs/accountInfoSettings.e2e.js
@@ -1,15 +1,17 @@
-import { expect, driver } from '@wdio/globals';
-
-import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page';
-import SidebarScreen from '../pageobjects/Home pages/SidebarScreen.page';
-import LoginEmailScreen from '../pageobjects/Login pages/LoginEmail.page';
-import LoginPasswordScreen from '../pageobjects/Login pages/LoginPassword.page';
-import AccountInformationScreen from '../pageobjects/Settings pages/AccountInformationScreen.page';
-import AccountSettingsScreen from '../pageobjects/Settings pages/AccountSettingsScreen.page';
-import ChangeEmailScreen from '../pageobjects/Settings pages/ChangeEmailScreen.page';
-import ChangePasswordScreen from '../pageobjects/Settings pages/ChangePasswordScreen.page';
-import ChangeUsernameScreen from '../pageobjects/Settings pages/ChangeUsernameScreen.page';
-import SettingsScreen from '../pageobjects/Settings pages/SettingsScreen.page';
+import { driver, expect } from '@wdio/globals';
+
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import SidebarScreen from '../pageobjects/Home/SidebarScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
+import AccountInformationScreen from '../pageobjects/Settings/AccountInformationScreen.page';
+import AccountSettingsScreen from '../pageobjects/Settings/AccountSettingsScreen.page';
+import ChangeCountryScreen from '../pageobjects/Settings/ChangeCountryScreen.page';
+import ChangeEmailScreen from '../pageobjects/Settings/ChangeEmailScreen.page';
+import ChangePasswordScreen from '../pageobjects/Settings/ChangePasswordScreen.page';
+import ChangeUsernameScreen from '../pageobjects/Settings/ChangeUsernameScreen.page';
+import ConfirmChangeCountryScreen from '../pageobjects/Settings/ConfirmChangeCountryScreen.page';
+import SettingsScreen from '../pageobjects/Settings/SettingsScreen.page';
import StartScreen from '../pageobjects/StartScreen.page';
import { createTestUser } from '../utils/createTestUser';
import { fetchOTP } from '../utils/fetchOTP';
@@ -28,11 +30,23 @@ describe('Account Information Settings', () => {
await ForYouScreen.openSideMenu();
await SidebarScreen.goToSettings();
await SettingsScreen.goToAccountSettings();
- await AccountSettingsScreen.goToAccountInformation();
});
- describe('Account Information Screen Tests', () => {
- it('should display account information screen', async () => {
+ describe('Account Settings Screen Tests', () => {
+ it('should navigate to change password screen from account settings screen', async () => {
+ await AccountSettingsScreen.goToChangePassword();
+ await ChangePasswordScreen.screenTitle.waitForDisplayed();
+ await expect(await ChangePasswordScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should correctly navigate back to account settings screen', async () => {
+ await ChangePasswordScreen.goBack();
+ await AccountSettingsScreen.screenTitle.waitForDisplayed();
+ await expect(await AccountSettingsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should navigate to and display account information screen', async () => {
+ await AccountSettingsScreen.goToAccountInformation();
await AccountInformationScreen.screenTitle.waitForDisplayed();
await expect(await AccountInformationScreen.isDisplayed()).toBe(true);
});
@@ -189,6 +203,82 @@ describe('Account Information Settings', () => {
});
});
+ describe('Change Country Screen Tests', () => {
+ before(async () => {
+ await AccountInformationScreen.goToChangeCountry();
+ });
+
+ it('should validate change country screen', async () => {
+ await ChangeCountryScreen.screenTitle.waitForDisplayed();
+ await expect(await ChangeCountryScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should find country when searching for it', async () => {
+ const countryName = 'Canada';
+ await ChangeCountryScreen.searchForCountry(countryName);
+ await expect(await ChangeCountryScreen.isCountryDisplayed(countryName)).toBe(true);
+ });
+
+ it('should go to the confirmation screen when selecting a country', async () => {
+ const countryName = 'Canada';
+ await ChangeCountryScreen.clickCountry(countryName);
+ await ConfirmChangeCountryScreen.changeButton.waitForDisplayed();
+ await expect(await ConfirmChangeCountryScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should handle canceling country change', async () => {
+ await ConfirmChangeCountryScreen.clickCancel();
+ await ChangeCountryScreen.screenTitle.waitForDisplayed();
+ await expect(await ChangeCountryScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should find country without searching', async () => {
+ const countryName = 'Egypt';
+ await ChangeCountryScreen.findCountry(countryName);
+ await expect(await ChangeCountryScreen.isCountryDisplayed(countryName)).toBe(true);
+ });
+
+ it('should show correct country in confirmation screen', async () => {
+ const countryName = 'Egypt';
+ await ChangeCountryScreen.clickCountry(countryName);
+ await ConfirmChangeCountryScreen.changeButton.waitForDisplayed();
+ await expect(await ConfirmChangeCountryScreen.isConfirmMessageDisplayed(countryName)).toBe(
+ true
+ );
+ });
+
+ it('should successfully change country and navigate back to account information screen', async () => {
+ await ConfirmChangeCountryScreen.clickChange();
+ await AccountInformationScreen.screenTitle.waitForDisplayed();
+ await expect(await AccountInformationScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should handle long country names correctly in confirm screen', async () => {
+ await AccountInformationScreen.goToChangeCountry();
+ const longCountryName = 'Saint Helena, Ascension and Tristan da Cunha';
+ await ChangeCountryScreen.findCountry(longCountryName);
+ await ChangeCountryScreen.clickCountry(longCountryName);
+ await ConfirmChangeCountryScreen.changeButton.waitForDisplayed();
+ await expect(
+ await ConfirmChangeCountryScreen.isConfirmMessageDisplayed(longCountryName)
+ ).toBe(true);
+ });
+
+ it('should successfully change to long country name and navigate to account information screen', async () => {
+ await ConfirmChangeCountryScreen.clickChange();
+ await AccountInformationScreen.screenTitle.waitForDisplayed();
+ await expect(await AccountInformationScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should show truncated version of country name in account information screen', async () => {
+ const longCountryName = 'Saint Helena, Ascension and Tristan da Cunha';
+ const truncatedCountryName = longCountryName.slice(0, 25) + '...';
+ await expect(await AccountInformationScreen.selectedCountry.getText()).toBe(
+ truncatedCountryName
+ );
+ });
+ });
+
describe('Change Password Screen Tests', () => {
before(async () => {
await AccountInformationScreen.goToChangePassword();
diff --git a/e2e/specs/dms.e2e.js b/e2e/specs/dms.e2e.js
new file mode 100644
index 000000000..ba5714eba
--- /dev/null
+++ b/e2e/specs/dms.e2e.js
@@ -0,0 +1,176 @@
+import { expect, driver } from '@wdio/globals';
+
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import SidebarScreen from '../pageobjects/Home/SidebarScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
+import ChatScreen from '../pageobjects/Messages/ChatScreen.page';
+import MessagesScreen from '../pageobjects/Messages/MessagesScreen.page';
+import ProfileScreen from '../pageobjects/Profile/ProfileScreen.page';
+import StartScreen from '../pageobjects/StartScreen.page';
+import { createTestUser } from '../utils/createTestUser';
+
+let testUser, otherUser;
+
+describe('DMs Flow', () => {
+ const login = async (user) => {
+ await StartScreen.openLoginScreen();
+ await LoginEmailScreen.enterEmail(user.email);
+ await LoginEmailScreen.clickNext();
+ await LoginPasswordScreen.enterPassword(user.password);
+ await LoginPasswordScreen.clickLogin();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ };
+
+ before(async () => {
+ testUser = await createTestUser();
+ otherUser = await createTestUser();
+ await login(testUser);
+ await ForYouScreen.switchToMessagingTab();
+ });
+
+ it('should initially show no conversations in the messages screen', async () => {
+ await MessagesScreen.screenTitle.waitForDisplayed();
+ await expect(await MessagesScreen.isNoConversationsMessageDisplayed()).toBe(true);
+ });
+
+ it('should open the new message drawer when clicking on the new message button', async () => {
+ await MessagesScreen.clickNewMessageButton();
+ await MessagesScreen.newMessageBackdrop.waitForDisplayed();
+ await expect(await MessagesScreen.isNewMessageDrawerDisplayed()).toBe(true);
+ });
+
+ it('should close drawer upon clicking on the backdrop', async () => {
+ await MessagesScreen.newMessageBackdrop.click();
+ await expect(await MessagesScreen.isNewMessageDrawerDisplayed()).toBe(false);
+ });
+
+ it('should handle long inputs in the search bar', async () => {
+ await MessagesScreen.clickNewMessageButton();
+ await MessagesScreen.searchFor('a'.repeat(420));
+ await expect(await MessagesScreen.newMessageSearchInput.getText()).toBe('a'.repeat(420));
+ });
+
+ it('should close drawer when clicking on cancel', async () => {
+ await MessagesScreen.cancelNewMessage();
+ await expect(await MessagesScreen.isNewMessageDrawerDisplayed()).toBe(false);
+ });
+
+ it('should still show no conversations after refreshing conversations list', async () => {
+ await MessagesScreen.refresh();
+ await expect(await MessagesScreen.isNoConversationsMessageDisplayed()).toBe(true);
+ });
+
+ it('should go to messages screen from the sidebar', async () => {
+ await MessagesScreen.openSideMenu();
+ await SidebarScreen.goToChat();
+ await expect(await MessagesScreen.screenTitle.waitForDisplayed()).toBe(true);
+ });
+
+ it('should handle no users found when searching in new message drawer', async () => {
+ await MessagesScreen.clickNewMessageButton();
+ await MessagesScreen.searchFor(`user${new Date()}`);
+ await expect(await MessagesScreen.isNoUsersFoundDisplayed()).toBe(true);
+ });
+
+ it('should handle finding users by searching for them', async () => {
+ await MessagesScreen.searchFor(otherUser.username);
+ await driver.pause(1000);
+ await expect(await MessagesScreen.newMessageRecipients.length).toBeGreaterThan(0);
+ });
+
+ it('should correctly open the chat screen when clicking on a user', async () => {
+ await MessagesScreen.newMessageRecipients[0].click();
+ const recipientDisplayName = await MessagesScreen.recipientDisplayNames[0].getText();
+ const recipientUsername = await MessagesScreen.recipientUsernames[0].getText();
+ await ChatScreen.noMessagesText.waitForDisplayed();
+ await expect(await ChatScreen.isCorrectUsernameDisplayed(recipientUsername)).toBe(true);
+ await expect(await ChatScreen.isCorrectDisplayNameDisplayed(recipientDisplayName)).toBe(true);
+ });
+
+ it('should initially show no messages yet in the conversation', async () => {
+ await expect(await ChatScreen.isNoMessagesYetDisplayed()).toBe(true);
+ });
+
+ it('should be able to send a message', async () => {
+ const msg = 'Hello!';
+ await ChatScreen.typeMessage(msg);
+ await ChatScreen.sendMessage();
+ await expect(await ChatScreen.isMessageDisplayed(msg));
+ await expect(await ChatScreen.isMessageSentDisplayed()).toBe(true);
+ });
+
+ it('should handle very long messages', async () => {
+ const longMsg = 's'.repeat(911);
+ await ChatScreen.typeMessage(longMsg);
+ await ChatScreen.sendMessage();
+ await expect(await ChatScreen.isMessageDisplayed(longMsg));
+ await expect(await ChatScreen.isMessageSentDisplayed()).toBe(true);
+ });
+
+ it('should show the scroll to bottom icon when theres messages not in view', async () => {
+ const anotherLongMsg = 's'.repeat(911);
+ await ChatScreen.typeMessage(anotherLongMsg);
+ await ChatScreen.sendMessage();
+ await ChatScreen.scrollUp();
+ await expect(await ChatScreen.isScrollBottomDisplayed()).toBe(true);
+ });
+
+ it('should scroll to bottom of screen when clicking on its icon', async () => {
+ await ChatScreen.scrollToBottom();
+ await expect(await ChatScreen.isScrollBottomDisplayed()).toBe(false);
+ });
+
+ it('should go to user profile when clicking on the user name', async () => {
+ await ChatScreen.goToUserProfile(otherUser.displayName);
+ await expect(await ProfileScreen.displayName.waitForDisplayed()).toBe(true);
+ await expect(await ProfileScreen.displayName.getText()).toBe(otherUser.displayName);
+ });
+
+ it('should navigate to chat screen when clicking on the messaging icon', async () => {
+ await ProfileScreen.clickOnMessageButton();
+ await ChatScreen.messageInput.waitForDisplayed();
+ await expect(await ChatScreen.isCorrectDisplayNameDisplayed(otherUser.displayName)).toBe(true);
+ });
+
+ it('should show messages sent previously', async () => {
+ await expect(await ChatScreen.isMessageDisplayed('s'.repeat(911))).toBe(true);
+ });
+
+ it('should go back to conversations list and show the user and message preview', async () => {
+ await ChatScreen.goBack();
+ await ChatScreen.goBack();
+ await ChatScreen.goBack();
+ await MessagesScreen.screenTitle.waitForDisplayed();
+ await expect(await MessagesScreen.conversations.length).toBeGreaterThan(0);
+ await expect(await MessagesScreen.conversationUsernames[0].getText()).toBe(
+ `@${otherUser.username}`
+ );
+ await expect(await MessagesScreen.conversationPreviews[0].isDisplayed()).toBe(true);
+ });
+
+ it('show a new message badge when logging in as the other user', async () => {
+ await MessagesScreen.goBack();
+ await ForYouScreen.openSideMenu();
+ await SidebarScreen.logout();
+ await StartScreen.welcomeText.waitForDisplayed();
+ await login(otherUser);
+ await expect(await MessagesScreen.badgeCount.getText()).toBe('1');
+ });
+
+ it('should show new message with unread indicator', async () => {
+ await ForYouScreen.switchToMessagingTab();
+ await MessagesScreen.screenTitle.waitForDisplayed();
+ await expect(await MessagesScreen.conversations.length).toBeGreaterThan(0);
+ await expect(await MessagesScreen.conversationPreviews[0].isDisplayed()).toBe(true);
+ await expect(await MessagesScreen.conversationUnreadIndicator[0].isDisplayed()).toBe(true);
+ });
+
+ it('should remove indicator after reading the message', async () => {
+ await MessagesScreen.conversations[0].click();
+ await ChatScreen.messageInput.waitForDisplayed();
+ await ChatScreen.goBack();
+ await MessagesScreen.screenTitle.waitForDisplayed();
+ await expect(await MessagesScreen.conversationUnreadIndicator.length).toBe(0);
+ });
+});
diff --git a/e2e/specs/editProfile.e2e.js b/e2e/specs/editProfile.e2e.js
index 82405b3bf..d367b4c4b 100644
--- a/e2e/specs/editProfile.e2e.js
+++ b/e2e/specs/editProfile.e2e.js
@@ -1,14 +1,14 @@
import { expect } from '@wdio/globals';
-import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page';
-import OnboardingBioScreen from '../pageobjects/Home pages/OnboardingBioScreen.page';
-import OnboardingProfilePicScreen from '../pageobjects/Home pages/OnboardingProfilePicScreen.page';
-import OnboardingUsernameScreen from '../pageobjects/Home pages/OnboardingUsernameScreen.page';
-import SidebarScreen from '../pageobjects/Home pages/SidebarScreen.page';
-import LoginEmailScreen from '../pageobjects/Login pages/LoginEmail.page';
-import LoginPasswordScreen from '../pageobjects/Login pages/LoginPassword.page';
-import EditProfileScreen from '../pageobjects/Profile pages/EditProfileScreen.page';
-import ProfileScreen from '../pageobjects/Profile pages/ProfileScreen.page';
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import SidebarScreen from '../pageobjects/Home/SidebarScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
+import OnboardingBioScreen from '../pageobjects/Onboarding/OnboardingBioScreen.page';
+import OnboardingProfilePicScreen from '../pageobjects/Onboarding/OnboardingProfilePicScreen.page';
+import OnboardingUsernameScreen from '../pageobjects/Onboarding/OnboardingUsernameScreen.page';
+import EditProfileScreen from '../pageobjects/Profile/EditProfileScreen.page';
+import ProfileScreen from '../pageobjects/Profile/ProfileScreen.page';
import StartScreen from '../pageobjects/StartScreen.page';
import { createTestUser } from '../utils/createTestUser';
diff --git a/e2e/specs/explore.e2e.js b/e2e/specs/explore.e2e.js
new file mode 100644
index 000000000..7bdcd281b
--- /dev/null
+++ b/e2e/specs/explore.e2e.js
@@ -0,0 +1,309 @@
+import { $, driver, expect } from '@wdio/globals';
+
+import ExploreScreen from '../pageobjects/Explore/ExploreScreen.page';
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
+import ProfileScreen from '../pageobjects/Profile/ProfileScreen.page';
+import SearchFiltersScreen from '../pageobjects/Search/SearchFiltersScreen.page';
+import SearchScreen from '../pageobjects/Search/SearchScreen.page';
+import StartScreen from '../pageobjects/StartScreen.page';
+import { createTestUser } from '../utils/createTestUser';
+
+let testUser;
+
+describe('Explore and Search Flow', () => {
+ const login = async () => {
+ await StartScreen.openLoginScreen();
+ await LoginEmailScreen.enterEmail(testUser.email);
+ await LoginEmailScreen.clickNext();
+ await LoginPasswordScreen.enterPassword(testUser.password);
+ await LoginPasswordScreen.clickLogin();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ };
+
+ const ensureOnExplore = async () => {
+ try {
+ await SearchScreen.searchBack.waitForDisplayed();
+ await SearchScreen.searchBack.click();
+ await driver.pause(500);
+ } catch {
+ // Not in search results screen, continue
+ }
+
+ try {
+ await SearchScreen.cancelSearch.waitForDisplayed();
+ await SearchScreen.cancelSearch.click();
+ await driver.pause(500);
+ } catch {
+ // Not in search input mode, continue
+ }
+
+ const exploreTab = await $('~ExploreTab');
+ await exploreTab.waitForDisplayed({ timeout: 5000 });
+ await exploreTab.click();
+ await driver.pause(500);
+
+ await ExploreScreen.forYouTab.waitForDisplayed({ timeout: 5000 });
+ await ExploreScreen.waitForContentToLoad();
+ };
+
+ before(async () => {
+ testUser = await createTestUser();
+ await login();
+ });
+
+ describe('Explore Tab Navigation', () => {
+ before(async () => {
+ await ForYouScreen.switchToExploreTab();
+ await ExploreScreen.waitForContentToLoad();
+ });
+
+ it('should display ForYou tab by default', async () => {
+ await expect(await ExploreScreen.forYouTab.waitForDisplayed()).toBe(true);
+ });
+
+ it('should switch to Trending tab', async () => {
+ await ExploreScreen.switchToTrendingTab();
+ await expect(await ExploreScreen.trendingList.waitForDisplayed()).toBe(true);
+ });
+
+ it('should switch to News tab', async () => {
+ await ExploreScreen.switchToNewsTab();
+ await expect(await ExploreScreen.newsList.waitForDisplayed()).toBe(true);
+ });
+
+ it('should switch to Sports tab', async () => {
+ await ExploreScreen.switchToSportsTab();
+ await expect(await ExploreScreen.isTrendingTabDisplayed()).toBe(true);
+ });
+
+ it('should switch to Entertainment tab', async () => {
+ await ExploreScreen.switchToEntertainmentTab();
+ await expect(await ExploreScreen.isTrendingTabDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Search Screen Basic Flow', () => {
+ before(async () => {
+ await ensureOnExplore();
+ await ExploreScreen.switchToForYouTab();
+ });
+
+ it('should open search from explore', async () => {
+ await ExploreScreen.openSearch();
+ await expect(await SearchScreen.searchInput.waitForDisplayed()).toBe(true);
+ });
+
+ it('should display empty state message when no query', async () => {
+ await expect(await SearchScreen.suggestionsEmpty.waitForDisplayed()).toBe(true);
+ });
+
+ it('should show cancel button', async () => {
+ await expect(await SearchScreen.cancelSearchButton.isDisplayed()).toBe(true);
+ });
+
+ it('should allow typing in search input', async () => {
+ await SearchScreen.searchInput.setValue('test');
+ const value = await SearchScreen.searchInput.getText();
+ await expect(value).toBe('test');
+ });
+
+ it('should show clear button when query is entered', async () => {
+ await expect(await SearchScreen.clearSearchButton.waitForDisplayed()).toBe(true);
+ });
+
+ it('should clear search input with clear button', async () => {
+ await SearchScreen.clearSearch();
+ const value = await SearchScreen.searchInput.getText();
+ await expect(value === 'Search').toBe(true);
+ });
+
+ it('should cancel search and return to explore', async () => {
+ await SearchScreen.cancelSearch();
+ await expect(await ExploreScreen.forYouTab.waitForDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Search Submission and Results', () => {
+ before(async () => {
+ await ensureOnExplore();
+ await ExploreScreen.openSearch();
+ });
+
+ it('should submit search query and show results', async () => {
+ await SearchScreen.searchFor('test');
+ await expect(await SearchScreen.isTopTabDisplayed()).toBe(true);
+ });
+
+ it('should display settings (filters) button', async () => {
+ await expect(await SearchScreen.settingsButton.isDisplayed()).toBe(true);
+ });
+
+ it('should display Top tab by default', async () => {
+ await expect(await SearchScreen.isTopTabDisplayed()).toBe(true);
+ });
+
+ it('should switch to Latest tab', async () => {
+ await SearchScreen.switchToLatestTab();
+ await expect(await SearchScreen.latestTab.isDisplayed()).toBe(true);
+ });
+
+ it('should switch to People tab', async () => {
+ await SearchScreen.switchToPeopleTab();
+ await expect(await SearchScreen.isPeopleTabDisplayed()).toBe(true);
+ });
+
+ it('should switch to Media tab', async () => {
+ await SearchScreen.switchToMediaTab();
+ await expect(await SearchScreen.mediaTab.isDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Search Filters', () => {
+ it('should open search filters screen', async () => {
+ await SearchScreen.settingsButton.click();
+ await SearchFiltersScreen.waitForScreen();
+ await expect(await SearchFiltersScreen.screen.isDisplayed()).toBe(true);
+ });
+
+ it('should display people filter section', async () => {
+ await expect(await SearchFiltersScreen.peopleFilterSection.isDisplayed()).toBe(true);
+ });
+
+ it('should display safety filter section', async () => {
+ await expect(await SearchFiltersScreen.safetyFilterSection.isDisplayed()).toBe(true);
+ });
+
+ it('should select People you follow option', async () => {
+ await SearchFiltersScreen.selectPeopleYouFollow();
+ await expect(await SearchFiltersScreen.isPeopleYouFollowSelected()).toBe(true);
+ });
+
+ it('should apply filters and close screen', async () => {
+ await SearchFiltersScreen.apply();
+ await expect(await SearchScreen.backButton.waitForDisplayed()).toBe(true);
+ });
+
+ it('should preserve filter selection when reopening', async () => {
+ await SearchScreen.settingsButton.click();
+ await SearchFiltersScreen.waitForScreen();
+ await expect(await SearchFiltersScreen.isPeopleYouFollowSelected()).toBe(true);
+ });
+
+ it('should reset to From anyone and apply', async () => {
+ await SearchFiltersScreen.selectFromAnyone();
+ await SearchFiltersScreen.apply();
+ await expect(await SearchScreen.backButton.waitForDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Trending Tab', () => {
+ before(async () => {
+ await SearchScreen.goBack();
+ await SearchScreen.goBack();
+ });
+
+ it('should navigate to Trending tab', async () => {
+ await ExploreScreen.switchToTrendingTab();
+ await ExploreScreen.waitForContentToLoad();
+ await expect(await ExploreScreen.isTrendingTabDisplayed()).toBe(true);
+ });
+
+ it('should display the trending list', async () => {
+ await expect(await ExploreScreen.trendingList.waitForDisplayed({ timeout: 5000 })).toBe(true);
+ });
+
+ it('should click on a trend and navigate to search results', async () => {
+ const clicked = await ExploreScreen.clickFirstTrendCard();
+ if (clicked) {
+ await expect(await SearchScreen.isTopTabDisplayed()).toBe(true);
+ await SearchScreen.goBack();
+ await expect(await ExploreScreen.isTrendingTabDisplayed()).toBe(true);
+ }
+ });
+ });
+
+ describe('Hashtag Search', () => {
+ before(async () => {
+ await ensureOnExplore();
+ });
+
+ it('should search for a hashtag and display results', async () => {
+ await ExploreScreen.openSearch();
+ await SearchScreen.searchFor('#test');
+ await expect(await SearchScreen.isTopTabDisplayed()).toBe(true);
+ });
+
+ it('should switch to People tab and verify results list', async () => {
+ await SearchScreen.switchToPeopleTab();
+ await expect(await SearchScreen.isPeopleTabDisplayed()).toBe(true);
+ const hasPeopleResults = await SearchScreen.isPeopleResultsDisplayed();
+ await expect(hasPeopleResults).toBe(true);
+ });
+
+ it('should return to explore', async () => {
+ await SearchScreen.goBack();
+ await expect(await ExploreScreen.forYouTab.waitForDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Search Empty State', () => {
+ before(async () => {
+ await ensureOnExplore();
+ await ExploreScreen.openSearch();
+ });
+
+ it('should search for gibberish and show empty or no results', async () => {
+ await SearchScreen.searchFor('zzzxxx999nonexistent42');
+ await expect(await SearchScreen.isTopTabDisplayed()).toBe(true);
+ });
+
+ it('should display empty state or no results message', async () => {
+ await driver.pause(1000);
+ const hasEmptyState = await SearchScreen.isEmptyStateDisplayed();
+ await expect(hasEmptyState).toBe(true);
+ });
+
+ it('should return to explore', async () => {
+ await SearchScreen.goBack();
+ await expect(await ExploreScreen.forYouTab.waitForDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Username Search with Profile Navigation', () => {
+ const targetUsername = 'notnowomar';
+
+ before(async () => {
+ await ensureOnExplore();
+ await ExploreScreen.openSearch();
+ });
+
+ it('should search for username and display results', async () => {
+ await SearchScreen.searchFor(`@${targetUsername}`);
+ await expect(await SearchScreen.isTopTabDisplayed()).toBe(true);
+ });
+
+ it('should find the user in Top tab results', async () => {
+ await driver.pause(1000);
+ await driver.pause(1000);
+ const hasUser = await SearchScreen.hasUserInResults(targetUsername);
+ await expect(hasUser).toBe(true);
+ });
+
+ it('should click on user and navigate to their profile', async () => {
+ await SearchScreen.clickOnUserInResults(targetUsername);
+ await expect(await ProfileScreen.username.waitForDisplayed()).toBe(true);
+ });
+
+ it('should display the correct username on profile', async () => {
+ const usernameText = await ProfileScreen.username.getText();
+ await expect(usernameText).toContain(targetUsername);
+ });
+
+ it('should go back to search results', async () => {
+ await ProfileScreen.goBack();
+ await expect(await SearchScreen.isTopTabDisplayed()).toBe(true);
+ });
+ });
+});
diff --git a/e2e/specs/login.e2e.js b/e2e/specs/login.e2e.js
index 38903959d..9a0eff45d 100644
--- a/e2e/specs/login.e2e.js
+++ b/e2e/specs/login.e2e.js
@@ -1,8 +1,8 @@
import { $, driver, expect } from '@wdio/globals';
-import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page';
-import LoginEmailScreen from '../pageobjects/Login pages/LoginEmail.page';
-import LoginPasswordScreen from '../pageobjects/Login pages/LoginPassword.page';
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
import StartScreen from '../pageobjects/StartScreen.page';
import { createTestUser } from '../utils/createTestUser';
diff --git a/e2e/specs/logout.e2e.js b/e2e/specs/logout.e2e.js
index 37d0aecb0..fe327f0a2 100644
--- a/e2e/specs/logout.e2e.js
+++ b/e2e/specs/logout.e2e.js
@@ -1,12 +1,12 @@
import { browser, driver, expect } from '@wdio/globals';
-import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page';
-import SidebarScreen from '../pageobjects/Home pages/SidebarScreen.page';
-import LoginEmailScreen from '../pageobjects/Login pages/LoginEmail.page';
-import LoginPasswordScreen from '../pageobjects/Login pages/LoginPassword.page';
-import AccountInformationScreen from '../pageobjects/Settings pages/AccountInformationScreen.page';
-import AccountSettingsScreen from '../pageobjects/Settings pages/AccountSettingsScreen.page';
-import SettingsScreen from '../pageobjects/Settings pages/SettingsScreen.page';
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import SidebarScreen from '../pageobjects/Home/SidebarScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
+import AccountInformationScreen from '../pageobjects/Settings/AccountInformationScreen.page';
+import AccountSettingsScreen from '../pageobjects/Settings/AccountSettingsScreen.page';
+import SettingsScreen from '../pageobjects/Settings/SettingsScreen.page';
import StartScreen from '../pageobjects/StartScreen.page';
import { createTestUser } from '../utils/createTestUser';
diff --git a/e2e/specs/notifications.e2e.js b/e2e/specs/notifications.e2e.js
new file mode 100644
index 000000000..79b439cb4
--- /dev/null
+++ b/e2e/specs/notifications.e2e.js
@@ -0,0 +1,248 @@
+import { expect, driver } from '@wdio/globals';
+
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
+import AllNotificationsScreen from '../pageobjects/Notifications/AllNotificationsScreen.page';
+import MentionsScreen from '../pageobjects/Notifications/MentionsScreen.page';
+import ProfileScreen from '../pageobjects/Profile/ProfileScreen.page';
+import StartScreen from '../pageobjects/StartScreen.page';
+import TweetDetailsScreen from '../pageobjects/Tweets/TweetDetailsScreen.page';
+import { changeUsername } from '../utils/changeUsername';
+import { createTestTweets } from '../utils/createTestTweets';
+import { createTestUser } from '../utils/createTestUser';
+import { followUser } from '../utils/followUser';
+import { getAccessToken } from '../utils/getAccessToken';
+import { receiveTweetActions } from '../utils/receiveTweetActions';
+
+let testUser, tweetId, token;
+
+describe('Notifications Flow', () => {
+ const login = async () => {
+ await StartScreen.openLoginScreen();
+ await LoginEmailScreen.enterEmail(testUser.email);
+ await LoginEmailScreen.clickNext();
+ await LoginPasswordScreen.enterPassword(testUser.password);
+ await LoginPasswordScreen.clickLogin();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ };
+
+ before(async () => {
+ testUser = await createTestUser();
+ token = await getAccessToken(testUser.email, testUser.password);
+ testUser.username = await changeUsername(token);
+ await login();
+ const tweetIds = await createTestTweets(token);
+ tweetId = tweetIds[0];
+ await ForYouScreen.switchToNotificationsTab();
+ });
+
+ it('should display Notifications screen', async () => {
+ await expect(await AllNotificationsScreen.screenTitle.waitForDisplayed()).toBe(true);
+ });
+
+ it('should initially show no notifications and show its message', async () => {
+ await expect(await AllNotificationsScreen.isNoNotificationsMessageDisplayed()).toBe(true);
+ });
+
+ describe('Follow notifications', () => {
+ before(async () => {
+ const numOfFollowers = 6;
+ await followUser(testUser.username, numOfFollowers);
+ });
+
+ it('should show all follow notifications after users follow me', async () => {
+ await AllNotificationsScreen.refresh();
+ await expect(await AllNotificationsScreen.followNotifications.length).toBe(6);
+ await expect(await AllNotificationsScreen.followBackButton.length).toBe(6);
+ });
+
+ it('should correctly handle following back a user from notifications list', async () => {
+ await AllNotificationsScreen.followBackButton[0].click();
+ await expect(await AllNotificationsScreen.followingButton.length).toBe(1);
+ });
+
+ it('should navigate to user profile when clicking on the notification', async () => {
+ const displayName = await AllNotificationsScreen.followNotifUserDisplayName[0].getText();
+ await AllNotificationsScreen.followNotifications[0].click();
+ await ProfileScreen.displayName.waitForDisplayed();
+ await expect(await ProfileScreen.displayName.getText()).toBe(displayName);
+ });
+
+ it('should verify following status in user profile', async () => {
+ await expect(await ProfileScreen.isFollowingButtonDisplayed()).toBe(true);
+ });
+
+ it('should update state of the follow button after unfollowing user in profile', async () => {
+ await ProfileScreen.goBack();
+ await AllNotificationsScreen.followingButton[0].click();
+ await expect(await AllNotificationsScreen.followingButton.length).toBe(0);
+ await expect(await AllNotificationsScreen.followBackButton.length).toBe(6);
+ await AllNotificationsScreen.followNotifications[0].click();
+ await expect(await ProfileScreen.isFollowBackButtonDisplayed()).toBe(true);
+ await ProfileScreen.goBack();
+ });
+ });
+
+ describe('Tweet Actions notifications', () => {
+ before(async () => {
+ const countPerAction = 1;
+ await receiveTweetActions(tweetId, countPerAction, {
+ like: true,
+ retweet: true,
+ quote: true,
+ reply: true,
+ });
+ });
+
+ it('should correctly show all tweet actions notifications after users interact with my tweets', async () => {
+ await AllNotificationsScreen.refresh();
+ await driver.pause(500);
+ await AllNotificationsScreen.refresh();
+ await expect(await AllNotificationsScreen.likeNotifications.length).toBe(1);
+ await expect(await AllNotificationsScreen.retweetNotifications.length).toBe(1);
+ await expect(await AllNotificationsScreen.quoteNotifications.length).toBe(1);
+ await expect(await AllNotificationsScreen.replyNotifications.length).toBe(1);
+ });
+
+ it('should go to tweet view when clicking on a reply notification', async () => {
+ await AllNotificationsScreen.replyNotifications[0].click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should go to tweet view when clicking on a quote notification', async () => {
+ await TweetDetailsScreen.goBack();
+ await AllNotificationsScreen.quoteNotifications[0].click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should go to tweet view when clicking on a retweet notification', async () => {
+ await TweetDetailsScreen.goBack();
+ await AllNotificationsScreen.retweetNotifications[0].click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should go to tweet view when clicking on a like notification', async () => {
+ await TweetDetailsScreen.goBack();
+ await AllNotificationsScreen.likeNotifications[0].click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should handle liking a tweet from the notification screen', async () => {
+ await TweetDetailsScreen.goBack();
+ const initialCount = parseInt(await AllNotificationsScreen.likeCount[0].getText());
+ await AllNotificationsScreen.likeIcon[0].click();
+ await expect(await AllNotificationsScreen.likeCount[0].getText()).toBe(
+ String(initialCount + 1)
+ );
+ });
+
+ it('should handle retweeting a tweet from the notification screen', async () => {
+ const initialCount = parseInt(await AllNotificationsScreen.retweetCount[0].getText());
+ await AllNotificationsScreen.retweetIcon[0].click();
+ await expect(await AllNotificationsScreen.retweetCount[0].getText()).toBe(
+ String(initialCount + 1)
+ );
+ });
+
+ it('should navigate to tweet view when clicking on the reply icon', async () => {
+ await AllNotificationsScreen.replyIcon[0].click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should show new notifications on refresh', async () => {
+ await TweetDetailsScreen.goBack();
+ await receiveTweetActions(tweetId, 5, {
+ like: true,
+ retweet: false,
+ quote: false,
+ reply: false,
+ });
+ await AllNotificationsScreen.refresh();
+ await driver.pause(500);
+ await AllNotificationsScreen.refresh();
+ await expect(await AllNotificationsScreen.likeNotifications.length).toBe(5);
+ });
+ });
+
+ describe('Mention Notifications', () => {
+ let newTweetId;
+ before(async () => {
+ const newUser = await createTestUser();
+ const newToken = await getAccessToken(newUser.email, newUser.password);
+ const ids = await createTestTweets(newToken);
+ newTweetId = ids[0];
+ await receiveTweetActions(
+ newTweetId,
+ 1,
+ { like: false, retweet: false, quote: false, reply: true },
+ `@${testUser.username}`
+ );
+ });
+
+ it('should show the mention notification in the all notifications tab', async () => {
+ await AllNotificationsScreen.refresh();
+ await driver.pause(500);
+ await AllNotificationsScreen.refresh();
+ await expect(await AllNotificationsScreen.mentionNotifications.length).toBe(1);
+ });
+
+ it('should navigate to tweet view when clicking on mention notification', async () => {
+ await AllNotificationsScreen.mentionNotifications[0].click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should initially show no mention notifications in mentions tab', async () => {
+ await TweetDetailsScreen.goBack();
+ await AllNotificationsScreen.goToMentionsTab();
+ await expect(await MentionsScreen.isNoNotificationsMessageDisplayed()).toBe(true);
+ });
+
+ it('should show the mention notification in the mentions tab', async () => {
+ await MentionsScreen.refresh();
+ await expect(await MentionsScreen.mentionNotifications.length).toBe(1);
+ });
+
+ it('should handle liking mention from mentions tab', async () => {
+ const initialCount = parseInt(await MentionsScreen.likeCount[0].getText());
+ await MentionsScreen.likeIcon[0].click();
+ await expect(await MentionsScreen.likeCount[0].getText()).toBe(String(initialCount + 1));
+ });
+
+ it('should handle retweeting mention from mentions tab', async () => {
+ const initialCount = parseInt(await MentionsScreen.retweetCount[0].getText());
+ await MentionsScreen.retweetIcon[0].click();
+ await expect(await MentionsScreen.retweetCount[0].getText()).toBe(String(initialCount + 1));
+ });
+
+ it('should navigate to mention tweet view when clicking on reply icon in mentions tab', async () => {
+ await MentionsScreen.replyIcon[0].click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should handle showing multiple new mention notifications', async () => {
+ await TweetDetailsScreen.goBack();
+ await receiveTweetActions(
+ newTweetId,
+ 3,
+ {
+ like: false,
+ retweet: false,
+ quote: true,
+ reply: false,
+ },
+ `@${testUser.username}`
+ );
+ await MentionsScreen.refresh();
+ await driver.pause(1000);
+ await expect(await MentionsScreen.mentionNotifications.length).toBe(4);
+ });
+ });
+});
diff --git a/e2e/specs/onboarding.e2e.js b/e2e/specs/onboarding.e2e.js
index 654afa1c2..f8fac12b4 100644
--- a/e2e/specs/onboarding.e2e.js
+++ b/e2e/specs/onboarding.e2e.js
@@ -1,13 +1,15 @@
-import { expect, driver } from '@wdio/globals';
-
-import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page';
-import OnboardingBioScreen from '../pageobjects/Home pages/OnboardingBioScreen.page';
-import OnboardingProfilePicScreen from '../pageobjects/Home pages/OnboardingProfilePicScreen.page';
-import OnboardingUsernameScreen from '../pageobjects/Home pages/OnboardingUsernameScreen.page';
-import SidebarScreen from '../pageobjects/Home pages/SidebarScreen.page';
-import SignupStep1Page from '../pageobjects/SignUp pages/SignupStep1.page';
-import SignupStep2Page from '../pageobjects/SignUp pages/SignupStep2.page';
-import SignupStep3Page from '../pageobjects/SignUp pages/SignupStep3.page';
+import { driver, expect } from '@wdio/globals';
+
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import SidebarScreen from '../pageobjects/Home/SidebarScreen.page';
+import OnboardingBioScreen from '../pageobjects/Onboarding/OnboardingBioScreen.page';
+import OnboardingFollowScreen from '../pageobjects/Onboarding/OnboardingFollowScreen.page';
+import OnboardingInterestsScreen from '../pageobjects/Onboarding/OnboardingInterestsScreen.page';
+import OnboardingProfilePicScreen from '../pageobjects/Onboarding/OnboardingProfilePicScreen.page';
+import OnboardingUsernameScreen from '../pageobjects/Onboarding/OnboardingUsernameScreen.page';
+import SignupStep1Page from '../pageobjects/SignUp/SignupStep1.page';
+import SignupStep2Page from '../pageobjects/SignUp/SignupStep2.page';
+import SignupStep3Page from '../pageobjects/SignUp/SignupStep3.page';
import StartScreen from '../pageobjects/StartScreen.page';
import { fetchOTP } from '../utils/fetchOTP';
@@ -22,7 +24,7 @@ describe('Onboarding Flow', () => {
SignupStep1Page.generateTestName(),
SignupStep1Page.generateTestEmail()
);
- await driver.pause(2000);
+ await SignupStep2Page.screenTitle.waitForDisplayed();
const otp = await fetchOTP(SignupStep1Page.testEmail, 'registration');
await SignupStep2Page.completeStep2(otp);
await SignupStep3Page.completeStep3(SignupStep1Page.testPassword);
@@ -67,6 +69,7 @@ describe('Onboarding Flow', () => {
});
it('should show error for short usernames', async () => {
+ await OnboardingUsernameScreen.screenTitle.waitForDisplayed();
await OnboardingUsernameScreen.enterUsername('ab');
await expect(await OnboardingUsernameScreen.isShortUsernameErrorMessageDisplayed()).toBe(
true
@@ -120,20 +123,42 @@ describe('Onboarding Flow', () => {
chosenSuggestion = await OnboardingUsernameScreen.usernameInput.getText();
});
- it('should proceed to the bio onboarding step with a valid username', async () => {
+ it('should proceed to the interests onboarding step when clicking next', async () => {
await OnboardingUsernameScreen.clickNext();
- await OnboardingBioScreen.screenTitle.waitForDisplayed();
- await expect(await OnboardingBioScreen.isDisplayed()).toBe(true);
+ await OnboardingInterestsScreen.screenTitle.waitForDisplayed();
+ await expect(await OnboardingInterestsScreen.isDisplayed()).toBe(true);
});
it('should preserve username when navigating back to the username screen', async () => {
- await OnboardingBioScreen.goBack();
+ await OnboardingInterestsScreen.goBack();
await OnboardingUsernameScreen.screenTitle.waitForDisplayed();
await expect(await OnboardingUsernameScreen.isNextButtonEnabled()).toBe(true);
await expect(await OnboardingUsernameScreen.usernameInput.getText()).toBe(chosenSuggestion);
await OnboardingUsernameScreen.clickNext();
});
+ it('should enable next button upon selecting at least one interest', async () => {
+ await OnboardingInterestsScreen.clickInterests(['Medical']); // Select
+ await expect(await OnboardingInterestsScreen.isNoneSelectedMessageDisplayed()).toBe(false);
+ });
+
+ it('should allow deselecting interests', async () => {
+ await OnboardingInterestsScreen.clickInterests(['Medical']); // Deselect
+ await driver.pause(100);
+ await expect(await OnboardingInterestsScreen.isNoneSelectedMessageDisplayed()).toBe(true);
+ });
+
+ it('should allow selecting multiple interests', async () => {
+ await OnboardingInterestsScreen.clickInterests(['Tech', 'Culture', 'Travel']);
+ await expect(await OnboardingInterestsScreen.isNoneSelectedMessageDisplayed()).toBe(false);
+ });
+
+ it('should proceed to the bio onboarding step when clicking next', async () => {
+ await OnboardingInterestsScreen.clickNext();
+ await OnboardingBioScreen.screenTitle.waitForDisplayed();
+ await expect(await OnboardingBioScreen.isDisplayed()).toBe(true);
+ });
+
it('should have next button disabled initially on bio screen', async () => {
await OnboardingBioScreen.screenTitle.waitForDisplayed();
await expect(await OnboardingBioScreen.isNextButtonEnabled()).toBe(false);
@@ -151,14 +176,54 @@ describe('Onboarding Flow', () => {
await expect(enteredBio.length).toBe(160);
});
- it('should complete onboarding and navigate to home screen upon completing bio', async () => {
+ it('should proceed to follow screen when clicking next', async () => {
await OnboardingBioScreen.enterBio('Hello, this is my bio!');
await OnboardingBioScreen.clickNext();
+ await OnboardingFollowScreen.screenTitle.waitForDisplayed();
+ await expect(await OnboardingFollowScreen.screenTitle.waitForDisplayed()).toBe(true);
+ });
+
+ it('should show follow suggestions on follow screen', async () => {
+ await OnboardingFollowScreen.screenTitle.waitForDisplayed();
+ await expect(await OnboardingFollowScreen.interactionButtons.length).toBeGreaterThan(0);
+ });
+
+ it('should have next button disabled initially on follow screen', async () => {
+ await OnboardingFollowScreen.screenTitle.waitForDisplayed();
+ await OnboardingFollowScreen.clickNext();
+ await expect(await OnboardingFollowScreen.screenTitle.isDisplayed()).toBe(true);
+ });
+
+ it('should enable next button upon selecting at least one follow suggestion', async () => {
+ await OnboardingFollowScreen.interactionButtons[0].click();
+ await expect(await OnboardingFollowScreen.isNextButtonEnabled()).toBe(true);
+ });
+
+ it('should disable next button again upon unfollowing that suggestion', async () => {
+ await OnboardingFollowScreen.interactionButtons[0].click();
+ await driver.pause(500);
+ await OnboardingFollowScreen.clickNext();
+ await expect(await OnboardingFollowScreen.screenTitle.isDisplayed()).toBe(true);
+ });
+
+ it('should handle following multiple users from suggestions', async () => {
+ for (let i = 0; i < (await OnboardingFollowScreen.interactionButtons.length); i++) {
+ await OnboardingFollowScreen.interactionButtons[i].click();
+ await driver.pause(500);
+ }
+ await expect(await OnboardingFollowScreen.isNextButtonEnabled()).toBe(true);
+ await expect(await OnboardingFollowScreen.followingButtons.length).toBe(
+ await OnboardingFollowScreen.interactionButtons.length
+ );
+ });
+
+ it('should navigate to for you screen upon clicking next', async () => {
+ await OnboardingFollowScreen.clickNext();
await ForYouScreen.forYouTab.waitForDisplayed();
- await expect(ForYouScreen.forYouTab).toBeDisplayed();
+ await expect(await ForYouScreen.forYouTab.isDisplayed()).toBe(true);
});
- it('should have the correct username in the profile after onboarding', async () => {
+ it('should reflect all changes in profile after onboarding', async () => {
await ForYouScreen.openSideMenu();
await expect(await SidebarScreen.currentUsername.getText()).toBe(chosenSuggestion);
});
@@ -192,17 +257,32 @@ describe('Onboarding Flow', () => {
await expect(await OnboardingUsernameScreen.isDisplayed()).toBe(true);
});
- it('should allow skipping username screen and proceed to bio screen', async () => {
+ it('should allow skipping username screen and proceed to interests screen', async () => {
chosenSuggestion = await OnboardingUsernameScreen.usernameInput.getText();
await OnboardingUsernameScreen.clickSkip();
+ await OnboardingInterestsScreen.screenTitle.waitForDisplayed();
+ await expect(await OnboardingInterestsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should still need one interest selected to proceed to bio screen', async () => {
+ await OnboardingInterestsScreen.clickInterests(['Food']);
+ await OnboardingInterestsScreen.clickNext();
await OnboardingBioScreen.screenTitle.waitForDisplayed();
await expect(await OnboardingBioScreen.isDisplayed()).toBe(true);
});
- it('should allow skipping bio screen and complete onboarding', async () => {
+ it('should allow skipping bio screen and proceed to follow screen', async () => {
await OnboardingBioScreen.clickSkip();
+ await OnboardingFollowScreen.screenTitle.waitForDisplayed();
+ await expect(await OnboardingFollowScreen.screenTitle.isDisplayed()).toBe(true);
+ });
+
+ it('should still need at least one follow to complete onboarding', async () => {
+ await OnboardingFollowScreen.interactionButtons[0].click();
+ await driver.pause(500);
+ await OnboardingFollowScreen.clickNext();
await ForYouScreen.forYouTab.waitForDisplayed();
- await expect(ForYouScreen.forYouTab).toBeDisplayed();
+ await expect(await ForYouScreen.forYouTab.isDisplayed()).toBe(true);
});
it('should have handled username skipping by defaulting to first suggestion', async () => {
diff --git a/e2e/specs/resetPassword.e2e.js b/e2e/specs/resetPassword.e2e.js
index 2445133c9..4c7c4ea1b 100644
--- a/e2e/specs/resetPassword.e2e.js
+++ b/e2e/specs/resetPassword.e2e.js
@@ -1,12 +1,12 @@
-import { expect, $, driver } from '@wdio/globals';
-
-import ForYouScreen from '../pageobjects/Home pages/ForYouScreen.page';
-import LoginEmailScreen from '../pageobjects/Login pages/LoginEmail.page';
-import LoginPasswordScreen from '../pageobjects/Login pages/LoginPassword.page';
-import ConfirmCodeResetPasswordScreen from '../pageobjects/Reset Password pages/ConfirmCodeResetPasswordScreen.page';
-import ConfirmMethodResetPasswordScreen from '../pageobjects/Reset Password pages/ConfirmMethodResetPasswordScreen.page';
-import EmailResetPasswordScreen from '../pageobjects/Reset Password pages/EmailResetPasswordScreen.page';
-import NewPasswordResetPasswordScreen from '../pageobjects/Reset Password pages/NewPasswordResetPasswordScreen.page';
+import { $, driver, expect } from '@wdio/globals';
+
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
+import ConfirmCodeResetPasswordScreen from '../pageobjects/Reset Password/ConfirmCodeResetPasswordScreen.page';
+import ConfirmMethodResetPasswordScreen from '../pageobjects/Reset Password/ConfirmMethodResetPasswordScreen.page';
+import EmailResetPasswordScreen from '../pageobjects/Reset Password/EmailResetPasswordScreen.page';
+import NewPasswordResetPasswordScreen from '../pageobjects/Reset Password/NewPasswordResetPasswordScreen.page';
import StartScreen from '../pageobjects/StartScreen.page';
import { createTestUser } from '../utils/createTestUser';
import { fetchOTP } from '../utils/fetchOTP';
diff --git a/e2e/specs/settings.e2e.js b/e2e/specs/settings.e2e.js
new file mode 100644
index 000000000..77827eaf1
--- /dev/null
+++ b/e2e/specs/settings.e2e.js
@@ -0,0 +1,138 @@
+import { expect } from '@wdio/globals';
+
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import SidebarScreen from '../pageobjects/Home/SidebarScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
+import AppearanceSettingsScreen from '../pageobjects/Settings/AppearanceSettingsScreen.page';
+import BlockedListScreen from '../pageobjects/Settings/BlockedListScreen.page';
+import ContentYouSeeScreen from '../pageobjects/Settings/ContentYouSeeScreen.page';
+import InterestsScreen from '../pageobjects/Settings/InterestsScreen.page';
+import MuteAndBlockScreen from '../pageobjects/Settings/MuteAndBlockScreen.page';
+import MutedListScreen from '../pageobjects/Settings/MutedListScreen.page';
+import PrivacySettingsScreen from '../pageobjects/Settings/PrivacySettingsScreen.page';
+import SettingsScreen from '../pageobjects/Settings/SettingsScreen.page';
+import StartScreen from '../pageobjects/StartScreen.page';
+import { blockUser } from '../utils/blockUser';
+import { createTestUser } from '../utils/createTestUser';
+import { muteUser } from '../utils/muteUser';
+
+let testUser;
+
+describe('Settings Flow', () => {
+ const login = async () => {
+ await StartScreen.openLoginScreen();
+ await LoginEmailScreen.enterEmail(testUser.email);
+ await LoginEmailScreen.clickNext();
+ await LoginPasswordScreen.enterPassword(testUser.password);
+ await LoginPasswordScreen.clickLogin();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ };
+
+ before(async () => {
+ testUser = await createTestUser();
+ await login();
+ await ForYouScreen.openSideMenu();
+ });
+
+ describe('Theme Switcher Settings', () => {
+ it('should handle changing theme from sidebar', async () => {
+ await SidebarScreen.openThemeSwitcher();
+ await SidebarScreen.chooseDarkTheme();
+ await expect(await SidebarScreen.darkThemeButton.waitForDisplayed()).toBe(true);
+ await SidebarScreen.chooseLightTheme();
+ await expect(await SidebarScreen.lightThemeButton.waitForDisplayed()).toBe(true);
+ await SidebarScreen.chooseSystemTheme();
+ await expect(await SidebarScreen.systemThemeButton.waitForDisplayed()).toBe(true);
+ });
+
+ it('should handle changing theme in appearance settings', async () => {
+ await SidebarScreen.goBack();
+ await ForYouScreen.openSideMenu();
+ await SidebarScreen.goToSettings();
+ await SettingsScreen.goToAppearanceSettings();
+ await AppearanceSettingsScreen.chooseDarkTheme();
+ await expect(await AppearanceSettingsScreen.darkThemeButton.waitForDisplayed()).toBe(true);
+ await AppearanceSettingsScreen.chooseLightTheme();
+ await expect(await AppearanceSettingsScreen.lightThemeButton.waitForDisplayed()).toBe(true);
+ await AppearanceSettingsScreen.chooseSystemTheme();
+ await expect(await AppearanceSettingsScreen.systemThemeButton.waitForDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Mute and Block Settings', () => {
+ before(async () => {
+ await AppearanceSettingsScreen.goBack();
+ await SettingsScreen.goToPrivacySettings();
+ await PrivacySettingsScreen.goToMuteAndBlock();
+ });
+
+ it('should display mute and block settings correctly', async () => {
+ await expect(await MuteAndBlockScreen.screenTitle.waitForDisplayed()).toBe(true);
+ await expect(await MuteAndBlockScreen.blockedAccountsList.isDisplayed()).toBe(true);
+ await expect(await MuteAndBlockScreen.mutedAccountsList.isDisplayed()).toBe(true);
+ });
+
+ it('should handle empty blocked list', async () => {
+ await MuteAndBlockScreen.goToBlockedList();
+ await expect(await BlockedListScreen.noBlockedAccounts.waitForDisplayed()).toBe(true);
+ });
+
+ it('should show blocked users in list', async () => {
+ await blockUser(testUser.email, testUser.password, 3);
+ await BlockedListScreen.refresh();
+ await expect(await BlockedListScreen.blockedAccountsList.length).toBe(3);
+ });
+
+ it('should unblock a user via blocked button', async () => {
+ await BlockedListScreen.unblockFirstUser();
+ await BlockedListScreen.refresh();
+ await expect(await BlockedListScreen.blockedAccountsList.length).toBe(2);
+ });
+
+ it('should handle empty muted list', async () => {
+ await BlockedListScreen.goBack();
+ await MuteAndBlockScreen.goToMutedList();
+ await expect(await MutedListScreen.noMutedAccounts.waitForDisplayed()).toBe(true);
+ });
+
+ it('should show muted users in list', async () => {
+ await muteUser(testUser.email, testUser.password, 3);
+ await MutedListScreen.refresh();
+ await expect(await MutedListScreen.mutedAccountsList.length).toBe(3);
+ });
+
+ it('should unmute a user via mute icon', async () => {
+ await MutedListScreen.unmuteFirstUser();
+ await MutedListScreen.refresh();
+ await expect(await MutedListScreen.mutedAccountsList.length).toBe(2);
+ });
+ });
+
+ describe('Interests Settings', () => {
+ before(async () => {
+ await MutedListScreen.goBack();
+ await MuteAndBlockScreen.goBack();
+ await PrivacySettingsScreen.goToContentYouSee();
+ await ContentYouSeeScreen.goToInterests();
+ });
+
+ it('should display interests settings correctly', async () => {
+ await expect(await InterestsScreen.screenTitle.waitForDisplayed()).toBe(true);
+ });
+
+ it('should have save button disabled when no interests are picked', async () => {
+ await expect(await InterestsScreen.isSaveButtonEnabled()).toBe(false);
+ });
+
+ it('should have save button enabled when interests are picked', async () => {
+ await InterestsScreen.selectInterests(['Finance', 'General']);
+ await expect(await InterestsScreen.isSaveButtonEnabled()).toBe(true);
+ });
+
+ it('should save interests and navigate back to content you see screen', async () => {
+ await InterestsScreen.saveChanges();
+ await expect(await ContentYouSeeScreen.screenTitle.waitForDisplayed()).toBe(true);
+ });
+ });
+});
diff --git a/e2e/specs/signup.e2e.js b/e2e/specs/signup.e2e.js
index dcb76b50e..ef38b32da 100644
--- a/e2e/specs/signup.e2e.js
+++ b/e2e/specs/signup.e2e.js
@@ -1,9 +1,9 @@
import { expect, driver } from '@wdio/globals';
-import OnboardingProfilePicScreen from '../pageobjects/Home pages/OnboardingProfilePicScreen.page';
-import SignupStep1Page from '../pageobjects/SignUp pages/SignupStep1.page';
-import SignupStep2Page from '../pageobjects/SignUp pages/SignupStep2.page';
-import SignupStep3Page from '../pageobjects/SignUp pages/SignupStep3.page';
+import OnboardingProfilePicScreen from '../pageobjects/Onboarding/OnboardingProfilePicScreen.page';
+import SignupStep1Page from '../pageobjects/SignUp/SignupStep1.page';
+import SignupStep2Page from '../pageobjects/SignUp/SignupStep2.page';
+import SignupStep3Page from '../pageobjects/SignUp/SignupStep3.page';
import StartScreen from '../pageobjects/StartScreen.page';
import { fetchOTP } from '../utils/fetchOTP';
diff --git a/e2e/specs/tweets.e2e.js b/e2e/specs/tweets.e2e.js
new file mode 100644
index 000000000..c0d70d1ae
--- /dev/null
+++ b/e2e/specs/tweets.e2e.js
@@ -0,0 +1,426 @@
+import { driver, expect } from '@wdio/globals';
+
+import FollowingScreen from '../pageobjects/Home/FollowingScreen.page';
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
+import StartScreen from '../pageobjects/StartScreen.page';
+import TweetComposerScreen from '../pageobjects/Tweets/TweetComposerScreen.page';
+import TweetDetailsScreen from '../pageobjects/Tweets/TweetDetailsScreen.page';
+import { createTestUser } from '../utils/createTestUser';
+
+let testUser;
+
+describe('Tweets Flow', () => {
+ const login = async () => {
+ await StartScreen.openLoginScreen();
+ await LoginEmailScreen.enterEmail(testUser.email);
+ await LoginEmailScreen.clickNext();
+ await LoginPasswordScreen.enterPassword(testUser.password);
+ await LoginPasswordScreen.clickLogin();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ };
+
+ before(async () => {
+ testUser = await createTestUser();
+ await login();
+ });
+
+ describe('Creating Tweet Tests', () => {
+ before(async () => {
+ await ForYouScreen.clickNewTweetButton();
+ });
+
+ it('should show tweet composer screen', async () => {
+ await expect(await TweetComposerScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should initially have post button disabled when composer is empty', async () => {
+ await expect(await TweetComposerScreen.isPostButtonEnabled()).toBe(false);
+ });
+
+ it('should enable post button upon entering any input', async () => {
+ await TweetComposerScreen.writeTweet('Hello!');
+ await expect(await TweetComposerScreen.isPostButtonEnabled()).toBe(true);
+ });
+
+ it('should disable posting tweet when input is too long', async () => {
+ await TweetComposerScreen.writeTweet('s'.repeat(281));
+ await expect(await TweetComposerScreen.isPostButtonEnabled()).toBe(false);
+ });
+
+ it('should show discard modal when going back with unsaved changes', async () => {
+ await TweetComposerScreen.goBack();
+ await expect(await TweetComposerScreen.isDiscardModalDisplayed()).toBe(true);
+ });
+
+ it('should stay on composer screen when canceling discard', async () => {
+ await TweetComposerScreen.cancelDiscardTweet();
+ await expect(await TweetComposerScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should discard tweet when discarding', async () => {
+ await TweetComposerScreen.goBack();
+ await TweetComposerScreen.discardTweet();
+ await expect(await ForYouScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should handle posting tweet with only text', async () => {
+ await ForYouScreen.clickNewTweetButton();
+ await TweetComposerScreen.tweetInput.waitForDisplayed();
+ await TweetComposerScreen.writeTweet('Hello World!');
+ await TweetComposerScreen.postTweet();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ await ForYouScreen.switchToFollowingTab();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ await driver.pause(500);
+ await expect(await FollowingScreen.tweetCard.length).toBe(1);
+ });
+
+ it('should verify posted tweet is the same as input', async () => {
+ await expect(await FollowingScreen.tweetContent[0].getText()).toBe('Hello World!');
+ });
+
+ it('should handle choosing media in composer', async () => {
+ await FollowingScreen.clickNewTweetButton();
+ await TweetComposerScreen.tweetInput.waitForDisplayed();
+ try {
+ await expect(await TweetComposerScreen.composerMediaImages.length).toBeGreaterThan(0);
+ await TweetComposerScreen.composerMediaImages[0].click();
+ await driver.pause(500);
+ await expect(await TweetComposerScreen.composerRemoveMedia.length).toBe(1);
+ } catch (error) {
+ console.error(error);
+ }
+ });
+
+ it('should max out at 4 images', async () => {
+ try {
+ await TweetComposerScreen.composerMediaImages[1].click();
+ await driver.pause(100);
+ await TweetComposerScreen.composerMediaImages[2].click();
+ await driver.pause(100);
+ await TweetComposerScreen.composerMediaImages[3].click();
+ await driver.pause(100);
+ await expect(await TweetComposerScreen.composerRemoveMedia.length).toBeGreaterThan(0);
+ } catch (error) {
+ console.error(error);
+ }
+ });
+
+ it('should handle removing media', async () => {
+ try {
+ for (let i = 0; i < 4; i++) {
+ await TweetComposerScreen.composerRemoveMedia[0].click();
+ await driver.pause(500);
+ }
+ await expect(await TweetComposerScreen.composerRemoveMedia.length).toBe(0);
+ } catch (error) {
+ console.error(error);
+ }
+ });
+
+ it('should handle posting tweet with media', async () => {
+ try {
+ await TweetComposerScreen.composerMediaImages[0].click();
+ await driver.pause(100);
+ await TweetComposerScreen.postTweet();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ await ForYouScreen.switchToFollowingTab();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ await driver.pause(500);
+ await expect(await FollowingScreen.tweetCard.length).toBe(2);
+ } catch (error) {
+ console.error(error);
+ }
+ });
+
+ it('should handle posting tweet with mention, hashtag, and link', async () => {
+ await FollowingScreen.clickNewTweetButton();
+ await TweetComposerScreen.tweetInput.waitForDisplayed();
+ await TweetComposerScreen.writeTweet(`@${testUser.username} #test https://raven.cmp27.space`);
+ await TweetComposerScreen.postTweet();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ await ForYouScreen.switchToFollowingTab();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ await driver.pause(500);
+ await expect(await FollowingScreen.tweetCard.length).toBe(3);
+ });
+ });
+
+ describe('Timeline Navigation Tests', () => {
+ before(async () => {
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ });
+
+ it('should switch between For You and Following tabs', async () => {
+ await expect(await FollowingScreen.isDisplayed()).toBe(true);
+
+ await ForYouScreen.forYouTab.click();
+ await driver.pause(300);
+ await expect(await ForYouScreen.forYouTab).toBeDisplayed();
+
+ await ForYouScreen.switchToFollowingTab();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ await expect(await FollowingScreen.screenTitle).toBeDisplayed();
+ });
+
+ it('should display tweet cards on timeline', async () => {
+ const tweetCards = await FollowingScreen.tweetCard;
+ await expect(tweetCards.length).toBeGreaterThan(0);
+ });
+
+ it('should navigate to tweet detail when clicking on a tweet', async () => {
+ const firstTweet = await FollowingScreen.tweetCard[0];
+ await firstTweet.click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should go back to timeline from tweet detail', async () => {
+ await TweetDetailsScreen.goBack();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ await expect(await FollowingScreen.isDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Thread View Tests', () => {
+ before(async () => {
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ const firstTweet = await FollowingScreen.tweetCard[0];
+ await firstTweet.click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ });
+
+ it('should display main tweet in detail view', async () => {
+ await expect(await TweetDetailsScreen.isMainTweetDisplayed()).toBe(true);
+ });
+
+ it('should display tweet content in detail view', async () => {
+ const contentElements = await TweetDetailsScreen.tweetContent;
+ await expect(contentElements.length).toBeGreaterThan(0);
+ });
+
+ it('should display tweet action buttons', async () => {
+ await expect(await TweetDetailsScreen.likeButton).toBeDisplayed();
+ await expect(await TweetDetailsScreen.retweetButton).toBeDisplayed();
+ await expect(await TweetDetailsScreen.replyButton).toBeDisplayed();
+ });
+
+ it('should display reply input at bottom', async () => {
+ await expect(await TweetDetailsScreen.replyInput).toBeDisplayed();
+ });
+
+ after(async () => {
+ await TweetDetailsScreen.goBack();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ });
+ });
+
+ describe('Reply Flow Tests', () => {
+ let initialReplyCount;
+
+ before(async () => {
+ const firstTweet = await FollowingScreen.tweetCard[0];
+ await firstTweet.click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ initialReplyCount = await TweetDetailsScreen.getReplyCountValue();
+ });
+
+ it('should show reply input with placeholder', async () => {
+ const replyInput = await TweetDetailsScreen.replyInput;
+ await expect(replyInput).toBeDisplayed();
+ });
+
+ it('should submit a reply using quick reply input', async () => {
+ await TweetDetailsScreen.typeReply('This is a test reply!');
+ await TweetDetailsScreen.submitReply();
+ await driver.pause(1000);
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should update reply count after replying', async () => {
+ await driver.pause(500);
+ const newReplyCount = await TweetDetailsScreen.getReplyCountValue();
+ expect(newReplyCount).toBe(initialReplyCount + 1);
+ });
+
+ it('should show the reply in the thread', async () => {
+ const replies = await TweetDetailsScreen.replyContainers;
+ await expect(replies.length).toBeGreaterThan(0);
+ });
+
+ it('should navigate to reply detail when clicking on a reply', async () => {
+ const replies = await TweetDetailsScreen.replyContainers;
+ if (replies.length > 0) {
+ await replies[0].click();
+ await driver.pause(500);
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ await TweetDetailsScreen.goBack();
+ }
+ });
+
+ after(async () => {
+ await TweetDetailsScreen.goBack();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ });
+ });
+
+ describe('Tweet Interaction Tests', () => {
+ it('should show delete tweet modal when deleting tweet', async () => {
+ await FollowingScreen.tweetDrawerButton[0].click();
+ await FollowingScreen.deleteTweet();
+ await FollowingScreen.deleteTweetModal.waitForDisplayed();
+ await expect(await FollowingScreen.isDeleteTweetModalDisplayed()).toBe(true);
+ });
+
+ it('should not delete tweet when canceling deletion', async () => {
+ const initialCount = 3;
+ await FollowingScreen.cancelDeleteTweet();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ await expect(await FollowingScreen.tweetCard.length).toBe(initialCount);
+ });
+
+ it('should delete tweet when confirming deletion', async () => {
+ const initialCount = await FollowingScreen.tweetCard.length;
+ await FollowingScreen.tweetDrawerButton[0].click();
+ await FollowingScreen.deleteTweet();
+ await FollowingScreen.confirmDeleteTweet();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ await driver.pause(500);
+ await expect(await FollowingScreen.tweetCard.length).toBe(initialCount - 1);
+ });
+
+ it('should show ai summary when clicking on its icon', async () => {
+ const aiButtons = await FollowingScreen.aiSummaryButton;
+ if (aiButtons.length > 0) {
+ await aiButtons[0].click();
+ await driver.pause(2000);
+ const aiTexts = await FollowingScreen.aiSummaryText;
+ if (aiTexts.length > 0) {
+ await expect(await aiTexts[0].waitForDisplayed()).toBe(true);
+ }
+ }
+ });
+
+ it('should handle liking tweet', async () => {
+ const initialLikeCount = await FollowingScreen.tweetLikeCount[0].getText();
+ await FollowingScreen.tweetLikeButton[0].click();
+ await driver.pause(300);
+ const updatedLikeCount = await FollowingScreen.tweetLikeCount[0].getText();
+ expect(parseInt(updatedLikeCount)).toBe(parseInt(initialLikeCount) + 1);
+ });
+
+ it('should handle unliking tweet', async () => {
+ const initialLikeCount = await FollowingScreen.tweetLikeCount[0].getText();
+ await FollowingScreen.tweetLikeButton[0].click();
+ await driver.pause(300);
+ const updatedLikeCount = await FollowingScreen.tweetLikeCount[0].getText();
+ expect(parseInt(updatedLikeCount)).toBe(parseInt(initialLikeCount) - 1);
+ });
+
+ it('should handle retweeting tweet', async () => {
+ const initialRetweetCount = await FollowingScreen.tweetRetweetCount[0].getText();
+ await FollowingScreen.tweetRetweetButton[0].click();
+ await FollowingScreen.chooseRepostFromDrawer();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ await driver.pause(300);
+ const updatedRetweetCount = await FollowingScreen.tweetRetweetCount[0].getText();
+ expect(parseInt(updatedRetweetCount)).toBe(parseInt(initialRetweetCount) + 1);
+ });
+
+ it('should handle undo retweet', async () => {
+ const initialRetweetCount = await FollowingScreen.tweetRetweetCount[0].getText();
+ await FollowingScreen.tweetRetweetButton[0].click();
+ await FollowingScreen.chooseUndoRepostFromDrawer();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ await driver.pause(300);
+ const updatedRetweetCount = await FollowingScreen.tweetRetweetCount[0].getText();
+ expect(parseInt(updatedRetweetCount)).toBe(parseInt(initialRetweetCount) - 1);
+ });
+
+ it('should handle quoting tweet', async () => {
+ await FollowingScreen.tweetRetweetButton[1].click();
+ await FollowingScreen.chooseQuoteFromDrawer();
+ await TweetComposerScreen.tweetInput.waitForDisplayed();
+ await TweetComposerScreen.writeTweet("I'm quoting this tweet!");
+ await TweetComposerScreen.postTweet();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ await ForYouScreen.switchToFollowingTab();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ await driver.pause(500);
+ const tweetContent = await FollowingScreen.tweetContent[0].getText();
+ expect(tweetContent).toContain("I'm quoting this tweet!");
+ });
+ });
+
+ describe('Reply via Composer Tests', () => {
+ before(async () => {
+ const firstTweet = await FollowingScreen.tweetCard[2];
+ await firstTweet.click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ });
+
+ it('should open full composer when clicking reply button', async () => {
+ await TweetDetailsScreen.replyButton.click();
+ await TweetComposerScreen.tweetInput.waitForDisplayed();
+ await expect(await TweetComposerScreen.isDisplayed()).toBe(true);
+ });
+
+ it('should show replying to context in composer', async () => {
+ await expect(await TweetComposerScreen.tweetInput).toBeDisplayed();
+ });
+
+ it('should submit reply via full composer', async () => {
+ await TweetComposerScreen.writeTweet('Reply via full composer!');
+ await TweetComposerScreen.postTweet();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ await expect(await TweetDetailsScreen.isDisplayed()).toBe(true);
+ });
+
+ after(async () => {
+ await TweetDetailsScreen.goBack();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ });
+ });
+
+ describe('Tweet Detail Interaction Tests', () => {
+ before(async () => {
+ const firstTweet = await FollowingScreen.tweetCard[2];
+ await firstTweet.click();
+ await TweetDetailsScreen.screenTitle.waitForDisplayed();
+ });
+
+ it('should like tweet from detail view', async () => {
+ const initialCount = await TweetDetailsScreen.getLikeCountValue();
+ await TweetDetailsScreen.toggleLike();
+ await driver.pause(300);
+ const newCount = await TweetDetailsScreen.getLikeCountValue();
+ expect(newCount).toBe(initialCount + 1);
+ });
+
+ it('should retweet from detail view', async () => {
+ await TweetDetailsScreen.openRetweetMenu();
+ await TweetDetailsScreen.repostOption.waitForDisplayed();
+ await expect(await TweetDetailsScreen.repostOption).toBeDisplayed();
+ await expect(await TweetDetailsScreen.quoteOption).toBeDisplayed();
+ await driver.back();
+ await driver.pause(300);
+ });
+
+ it('should show tweet drawer options', async () => {
+ const drawerButton = await TweetDetailsScreen.tweetDrawerButton;
+ if (await drawerButton.isDisplayed()) {
+ await drawerButton.click();
+ await driver.pause(500);
+ await driver.back();
+ await driver.pause(300);
+ }
+ });
+
+ after(async () => {
+ await TweetDetailsScreen.goBack();
+ await FollowingScreen.screenTitle.waitForDisplayed();
+ });
+ });
+});
diff --git a/e2e/specs/userProfile.e2e.js b/e2e/specs/userProfile.e2e.js
new file mode 100644
index 000000000..ea13b336c
--- /dev/null
+++ b/e2e/specs/userProfile.e2e.js
@@ -0,0 +1,363 @@
+import { driver, expect } from '@wdio/globals';
+
+import ForYouScreen from '../pageobjects/Home/ForYouScreen.page';
+import SidebarScreen from '../pageobjects/Home/SidebarScreen.page';
+import LoginEmailScreen from '../pageobjects/Login/LoginEmail.page';
+import LoginPasswordScreen from '../pageobjects/Login/LoginPassword.page';
+import AllNotificationsScreen from '../pageobjects/Notifications/AllNotificationsScreen.page';
+import ConnectionsScreen from '../pageobjects/Profile/ConnectionsScreen.page';
+import ProfileScreen from '../pageobjects/Profile/ProfileScreen.page';
+import SearchScreen from '../pageobjects/Search/SearchScreen.page';
+import StartScreen from '../pageobjects/StartScreen.page';
+import { createTestTweets } from '../utils/createTestTweets';
+import { createTestUser } from '../utils/createTestUser';
+import { followUser } from '../utils/followUser';
+import { getAccessToken } from '../utils/getAccessToken';
+import { createReply, likeTweet } from '../utils/tweetActions';
+
+let testUser;
+let otherUserDisplayName;
+
+describe('User Profile Flow', () => {
+ const login = async (user) => {
+ await StartScreen.openLoginScreen();
+ await LoginEmailScreen.enterEmail(user.email);
+ await LoginEmailScreen.clickNext();
+ await LoginPasswordScreen.enterPassword(user.password);
+ await LoginPasswordScreen.clickLogin();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ };
+
+ const navigateToOwnProfile = async () => {
+ await ForYouScreen.openSideMenu();
+ await SidebarScreen.goToProfile();
+ await ProfileScreen.displayName.waitForDisplayed();
+ };
+
+ const navigateToOtherUserViaNotifications = async () => {
+ await ForYouScreen.switchToNotificationsTab();
+ await AllNotificationsScreen.screenTitle.waitForDisplayed();
+ await AllNotificationsScreen.followNotifications[0].click();
+ await ProfileScreen.displayName.waitForDisplayed();
+ otherUserDisplayName = await ProfileScreen.displayName.getText();
+ };
+
+ before(async () => {
+ testUser = await createTestUser();
+ await followUser(testUser.username, 1);
+ await login(testUser);
+ });
+
+ describe('Own Profile Navigation', () => {
+ it('should navigate to own profile from sidebar', async () => {
+ await navigateToOwnProfile();
+ await expect(await ProfileScreen.displayName.isDisplayed()).toBe(true);
+ });
+
+ it('should display profile information correctly', async () => {
+ await expect(await ProfileScreen.displayName.getText()).toBe(testUser.displayName);
+ });
+
+ it('should not show message button on own profile', async () => {
+ await expect(await ProfileScreen.isMessageButtonDisplayed()).toBe(false);
+ });
+
+ it('should navigate back to home from own profile', async () => {
+ await ProfileScreen.goBack();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ await expect(await ForYouScreen.forYouTab.isDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Other User Profile - Follow/Unfollow', () => {
+ before(async () => {
+ await navigateToOtherUserViaNotifications();
+ });
+
+ it('should display other user profile correctly', async () => {
+ await expect(await ProfileScreen.displayName.getText()).toBe(otherUserDisplayName);
+ });
+
+ it('should show Follow back button for user who follows you', async () => {
+ await expect(await ProfileScreen.isFollowBackButtonDisplayed()).toBe(true);
+ });
+
+ it('should show message button on other user profile', async () => {
+ await expect(await ProfileScreen.isMessageButtonDisplayed()).toBe(true);
+ });
+
+ it('should follow user when clicking Follow back button', async () => {
+ await ProfileScreen.followBackUser();
+ await expect(await ProfileScreen.isFollowingButtonDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Profile Content Tabs', () => {
+ let accessToken;
+ let tweetId;
+
+ before(async () => {
+ accessToken = await getAccessToken(testUser.email, testUser.password);
+ const tweetIds = await createTestTweets(accessToken, 1);
+ tweetId = tweetIds[0];
+ await createReply(accessToken, tweetId);
+ await likeTweet(accessToken, tweetId);
+
+ await ProfileScreen.goBack();
+ await navigateToOwnProfile();
+ });
+
+ it('should display Posts tab by default', async () => {
+ await expect(await ProfileScreen.postsTab.isDisplayed()).toBe(true);
+ });
+
+ it('should show at least one tweet in Posts tab', async () => {
+ await ProfileScreen.switchToPostsTab();
+ await ProfileScreen.refresh();
+ const tweets = await ProfileScreen.tweetCards;
+ await expect(tweets.length).toBeGreaterThan(0);
+ await expect(await tweets[0].isDisplayed()).toBe(true);
+ });
+
+ it('should switch to Replies tab and show at least one reply', async () => {
+ await ProfileScreen.switchToRepliesTab();
+ await ProfileScreen.refresh();
+ const tweets = await ProfileScreen.tweetCards;
+ await expect(tweets.length).toBeGreaterThan(0);
+ await expect(await tweets[0].isDisplayed()).toBe(true);
+ });
+
+ it('should switch to Media tab', async () => {
+ await ProfileScreen.switchToMediaTab();
+ await ProfileScreen.refresh();
+ await expect(await ProfileScreen.mediaTab.isDisplayed()).toBe(true);
+ });
+
+ it('should switch to Likes tab and show at least one liked tweet', async () => {
+ await ProfileScreen.switchToLikesTab();
+ await ProfileScreen.refresh();
+ const tweets = await ProfileScreen.tweetCards;
+ await expect(tweets.length).toBeGreaterThan(0);
+ await expect(await tweets[0].isDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Following/Followers Lists from Profile', () => {
+ before(async () => {
+ await ProfileScreen.switchToPostsTab();
+ });
+
+ it('should navigate to followers list from profile stats', async () => {
+ await ProfileScreen.goToFollowers();
+ await ConnectionsScreen.followersTab.waitForDisplayed();
+ await expect(await ConnectionsScreen.isFollowersTabDisplayed()).toBe(true);
+ });
+
+ it('should display all tabs in connections screen', async () => {
+ await expect(await ConnectionsScreen.isFollowingTabDisplayed()).toBe(true);
+ await expect(await ConnectionsScreen.isFollowersTabDisplayed()).toBe(true);
+ await expect(await ConnectionsScreen.isFollowersYouKnowTabDisplayed()).toBe(true);
+ });
+
+ it('should switch to following tab', async () => {
+ await ConnectionsScreen.switchToFollowingTab();
+ await driver.pause(500);
+ await expect(await ConnectionsScreen.isFollowingTabDisplayed()).toBe(true);
+ });
+
+ it('should switch to followers you know tab', async () => {
+ await ConnectionsScreen.switchToFollowersYouKnowTab();
+ await driver.pause(500);
+ await expect(await ConnectionsScreen.isFollowersYouKnowTabDisplayed()).toBe(true);
+ });
+
+ it('should switch back to followers tab', async () => {
+ await ConnectionsScreen.switchToFollowersTab();
+ await driver.pause(500);
+ await expect(await ConnectionsScreen.isFollowersTabDisplayed()).toBe(true);
+ });
+
+ it('should go back to profile from followers list', async () => {
+ await ConnectionsScreen.goBack();
+ await ProfileScreen.displayName.waitForDisplayed();
+ await expect(await ProfileScreen.displayName.isDisplayed()).toBe(true);
+ });
+
+ it('should navigate to following list from profile stats', async () => {
+ await ProfileScreen.goToFollowing();
+ await ConnectionsScreen.followingTab.waitForDisplayed();
+ await expect(await ConnectionsScreen.isFollowingTabDisplayed()).toBe(true);
+ });
+
+ it('should go back to profile from following list', async () => {
+ await ConnectionsScreen.goBack();
+ await ProfileScreen.displayName.waitForDisplayed();
+ await expect(await ProfileScreen.displayName.isDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Following/Followers Lists from Sidebar', () => {
+ before(async () => {
+ await ProfileScreen.goBack();
+ await AllNotificationsScreen.goToForYouScreen();
+ });
+
+ it('should navigate to following list from sidebar', async () => {
+ await ForYouScreen.openSideMenu();
+ await SidebarScreen.goToFollowing();
+ await ConnectionsScreen.followingTab.waitForDisplayed();
+ await expect(await ConnectionsScreen.isFollowingTabDisplayed()).toBe(true);
+ });
+
+ it('should go back to home from following list', async () => {
+ await ConnectionsScreen.goBack();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ await expect(await ForYouScreen.forYouTab.isDisplayed()).toBe(true);
+ });
+
+ it('should navigate to followers list from sidebar', async () => {
+ await ForYouScreen.openSideMenu();
+ await SidebarScreen.goToFollowers();
+ await ConnectionsScreen.followersTab.waitForDisplayed();
+ await expect(await ConnectionsScreen.isFollowersTabDisplayed()).toBe(true);
+ });
+
+ it('should go back to home from followers list', async () => {
+ await ConnectionsScreen.goBack();
+ await ForYouScreen.forYouTab.waitForDisplayed();
+ await expect(await ForYouScreen.forYouTab.isDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Search for profiles', () => {
+ it('should navigate to search screen via explore tab', async () => {
+ await ForYouScreen.switchToExploreTab();
+ await SearchScreen.openFromExplore();
+ await SearchScreen.searchInput.waitForDisplayed();
+ await expect(await SearchScreen.searchInput.isDisplayed()).toBe(true);
+ });
+
+ it('should search for the other user profile and find results', async () => {
+ await SearchScreen.searchFor(otherUserDisplayName);
+ await driver.pause(500);
+ await expect(await SearchScreen.isTopTabDisplayed()).toBe(true);
+ });
+
+ it('should display all other tabs correctly in search results', async () => {
+ await expect(await SearchScreen.latestTab.isDisplayed()).toBe(true);
+ await expect(await SearchScreen.isPeopleTabDisplayed()).toBe(true);
+ await expect(await SearchScreen.mediaTab.isDisplayed()).toBe(true);
+ });
+
+ it('should switch to People tab and show user results', async () => {
+ await SearchScreen.switchToPeopleTab();
+ await driver.pause(500);
+ const hasUser = await SearchScreen.hasUserInResults(otherUserDisplayName);
+ await expect(hasUser).toBe(true);
+ });
+
+ it('should navigate to user profile from search results', async () => {
+ await SearchScreen.clickOnUserInResults(otherUserDisplayName);
+ await ProfileScreen.displayName.waitForDisplayed();
+ await expect(await ProfileScreen.displayName.getText()).toBe(otherUserDisplayName);
+ });
+
+ it('should go back to search results from user profile', async () => {
+ await ProfileScreen.goBack();
+ await SearchScreen.peopleTab.waitForDisplayed();
+ await expect(await SearchScreen.isPeopleTabDisplayed()).toBe(true);
+ });
+ });
+
+ describe('Mute/Unmute Flow', () => {
+ before(async () => {
+ await SearchScreen.goBack();
+ await SearchScreen.goToForYouScreen();
+ await navigateToOtherUserViaNotifications();
+ });
+
+ it('should open profile menu dropdown and show Mute option', async () => {
+ await ProfileScreen.openMenuDropdown();
+ await expect(await ProfileScreen.muteButton.isDisplayed()).toBe(true);
+ });
+
+ it('should mute user and show muted indicator', async () => {
+ await ProfileScreen.muteUser();
+ await expect(await ProfileScreen.isMutedIndicatorDisplayed()).toBe(true);
+ });
+
+ it('should show Unmute option in dropdown after muting', async () => {
+ await ProfileScreen.openMenuDropdown();
+ await expect(await ProfileScreen.unmuteButton.isDisplayed()).toBe(true);
+ });
+
+ it('should unmute user through dropdown menu', async () => {
+ await ProfileScreen.unmuteUser();
+ const isMutedDisplayed = await ProfileScreen.mutedIndicator.isDisplayed();
+ await expect(isMutedDisplayed).toBe(false);
+ });
+
+ it('should unmute user through indicator link', async () => {
+ await ProfileScreen.openMenuDropdown();
+ await ProfileScreen.muteUser();
+ await ProfileScreen.unmuteFromIndicator();
+ const isMutedDisplayed = await ProfileScreen.mutedIndicator.isDisplayed();
+ await expect(isMutedDisplayed).toBe(false);
+ });
+ });
+
+ describe('Block/Unblock Flow', () => {
+ it('should show Block option in dropdown menu', async () => {
+ await ProfileScreen.openMenuDropdown();
+ await expect(await ProfileScreen.blockButtonMenu.isDisplayed()).toBe(true);
+ });
+
+ it('should block user and show Blocked button', async () => {
+ await ProfileScreen.blockUser();
+ await expect(await ProfileScreen.isBlockedButtonDisplayed()).toBe(true);
+ });
+
+ it('should hide message button after blocking', async () => {
+ await expect(await ProfileScreen.isMessageButtonDisplayed()).toBe(false);
+ });
+
+ it('should not show mute option for blocked user', async () => {
+ await ProfileScreen.openMenuDropdown();
+ const isMuteDisplayed = await ProfileScreen.muteButton.isDisplayed();
+ await expect(isMuteDisplayed).toBe(false);
+ });
+
+ it('should show Unblock option in dropdown for blocked user', async () => {
+ await expect(await ProfileScreen.unblockButton.isDisplayed()).toBe(true);
+ await ProfileScreen.closeMenuDropdown();
+ });
+
+ it('should show blocked user message', async () => {
+ await expect(await ProfileScreen.isBlockedMessageDisplayed()).toBe(true);
+ });
+
+ it('should allow viewing posts of blocked user', async () => {
+ await ProfileScreen.viewBlockedUserPosts();
+ await driver.pause(500);
+ const isBlockedMsgDisplayed = await ProfileScreen.blockedMessage.isDisplayed();
+ await expect(isBlockedMsgDisplayed).toBe(false);
+ });
+
+ it('should unblock user through dropdown menu', async () => {
+ await ProfileScreen.openMenuDropdown();
+ await ProfileScreen.unblockUser();
+ await expect(await ProfileScreen.isFollowButtonDisplayed()).toBe(true);
+ });
+
+ it('should show message button after unblocking', async () => {
+ await expect(await ProfileScreen.isMessageButtonDisplayed()).toBe(true);
+ });
+
+ it('should unblock user through blocked button', async () => {
+ await ProfileScreen.openMenuDropdown();
+ await ProfileScreen.blockUser();
+ await ProfileScreen.unblockFromButton();
+ await expect(await ProfileScreen.isFollowButtonDisplayed()).toBe(true);
+ });
+ });
+});
diff --git a/e2e/utils/blockUser.js b/e2e/utils/blockUser.js
new file mode 100644
index 000000000..f0ba6f9c5
--- /dev/null
+++ b/e2e/utils/blockUser.js
@@ -0,0 +1,35 @@
+import { createTestUser } from './createTestUser';
+
+const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000';
+
+export async function blockUser(email, password, count = 1) {
+ let response = await fetch(`${BASE_URL}/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Client-Type': 'mobile',
+ },
+ body: JSON.stringify({
+ identifier: email,
+ password: password,
+ }),
+ }).then((res) => res.json());
+
+ const accessToken = response.data.accessToken;
+
+ for (let i = 0; i < count; i++) {
+ let blockedUser = await createTestUser();
+
+ response = await fetch(`${BASE_URL}/me/blocks/${blockedUser.username}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to block user: ${response.statusText}`);
+ }
+ }
+}
diff --git a/e2e/utils/changeUsername.js b/e2e/utils/changeUsername.js
new file mode 100644
index 000000000..7f5a685a3
--- /dev/null
+++ b/e2e/utils/changeUsername.js
@@ -0,0 +1,20 @@
+const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000';
+
+export async function changeUsername(accessToken) {
+ const newUsername = `u${new Date().getTime()}`;
+ const response = await fetch(`${BASE_URL}/me/settings/username`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({
+ newUsername: newUsername,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to change username: ${response.statusText}`);
+ }
+ return newUsername;
+}
diff --git a/e2e/utils/createHashtagTweets.js b/e2e/utils/createHashtagTweets.js
new file mode 100644
index 000000000..ac2348491
--- /dev/null
+++ b/e2e/utils/createHashtagTweets.js
@@ -0,0 +1,30 @@
+import { faker } from '@faker-js/faker';
+
+const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000';
+
+export async function createHashtagTweets(accessToken, hashtag, count = 5) {
+ const normalizedHashtag = hashtag.startsWith('#') ? hashtag : `#${hashtag}`;
+ const createdTweetsIds = [];
+
+ for (let i = 0; i < count; i++) {
+ const content = `${faker.lorem.sentence()} ${normalizedHashtag}`;
+
+ const response = await fetch(`${BASE_URL}/tweets`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({ content }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to create tweet with hashtag: ${response.statusText}`);
+ }
+
+ const res = await response.json();
+ createdTweetsIds.push(res.data.id);
+ }
+
+ return createdTweetsIds;
+}
diff --git a/e2e/utils/createTestTweets.js b/e2e/utils/createTestTweets.js
new file mode 100644
index 000000000..f9551283f
--- /dev/null
+++ b/e2e/utils/createTestTweets.js
@@ -0,0 +1,28 @@
+import { faker } from '@faker-js/faker';
+
+const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000';
+
+export async function createTestTweets(accessToken, count = 1) {
+ const createdTweetsIds = [];
+ let response;
+ for (let i = 0; i < count; i++) {
+ response = await fetch(`${BASE_URL}/tweets`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({
+ content: faker.lorem.sentence(),
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to create tweet: ${response.statusText}`);
+ }
+
+ const res = await response.json();
+ createdTweetsIds.push(res.data.id);
+ }
+ return createdTweetsIds;
+}
diff --git a/e2e/utils/followUser.js b/e2e/utils/followUser.js
new file mode 100644
index 000000000..6e4cae473
--- /dev/null
+++ b/e2e/utils/followUser.js
@@ -0,0 +1,34 @@
+import { createTestUser } from './createTestUser';
+const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000';
+
+export async function followUser(username, count = 1) {
+ for (let i = 0; i < count; i++) {
+ let follower = await createTestUser();
+
+ let response = await fetch(`${BASE_URL}/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Client-Type': 'mobile',
+ },
+ body: JSON.stringify({
+ identifier: follower.email,
+ password: follower.password,
+ }),
+ }).then((res) => res.json());
+
+ const accessToken = response.data.accessToken;
+
+ response = await fetch(`${BASE_URL}/users/${username}/following`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to follow user: ${response.statusText}`);
+ }
+ }
+}
diff --git a/e2e/utils/getAccessToken.js b/e2e/utils/getAccessToken.js
new file mode 100644
index 000000000..cb20aade6
--- /dev/null
+++ b/e2e/utils/getAccessToken.js
@@ -0,0 +1,17 @@
+const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000';
+
+export async function getAccessToken(email, password) {
+ const response = await fetch(`${BASE_URL}/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Client-Type': 'mobile',
+ },
+ body: JSON.stringify({
+ identifier: email,
+ password: password,
+ }),
+ }).then((res) => res.json());
+
+ return response.data.accessToken;
+}
diff --git a/e2e/utils/muteUser.js b/e2e/utils/muteUser.js
new file mode 100644
index 000000000..126e87d33
--- /dev/null
+++ b/e2e/utils/muteUser.js
@@ -0,0 +1,35 @@
+import { createTestUser } from './createTestUser';
+
+const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000';
+
+export async function muteUser(email, password, count = 1) {
+ let response = await fetch(`${BASE_URL}/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Client-Type': 'mobile',
+ },
+ body: JSON.stringify({
+ identifier: email,
+ password: password,
+ }),
+ }).then((res) => res.json());
+
+ const accessToken = response.data.accessToken;
+
+ for (let i = 0; i < count; i++) {
+ let mutedUser = await createTestUser();
+
+ response = await fetch(`${BASE_URL}/me/mutes/${mutedUser.username}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to mute user: ${response.statusText}`);
+ }
+ }
+}
diff --git a/e2e/utils/receiveTweetActions.js b/e2e/utils/receiveTweetActions.js
new file mode 100644
index 000000000..a17dea3c8
--- /dev/null
+++ b/e2e/utils/receiveTweetActions.js
@@ -0,0 +1,89 @@
+import { faker } from '@faker-js/faker';
+
+import { createTestUser } from './createTestUser';
+
+const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000';
+
+export async function receiveTweetActions(tweetId, count, actions, mention = '') {
+ for (let i = 0; i < count; i++) {
+ let user = await createTestUser();
+
+ let response = await fetch(`${BASE_URL}/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Client-Type': 'mobile',
+ },
+ body: JSON.stringify({
+ identifier: user.email,
+ password: user.password,
+ }),
+ }).then((res) => res.json());
+
+ const accessToken = response.data.accessToken;
+
+ if (actions.like) {
+ response = await fetch(`${BASE_URL}/tweets/${tweetId}/like`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ console.error(`Failed to like tweet: ${response.statusText}`);
+ }
+ }
+
+ if (actions.retweet) {
+ response = await fetch(`${BASE_URL}/tweets/${tweetId}/retweet`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ console.error(`Failed to retweet tweet: ${response.statusText}`);
+ }
+ }
+
+ if (actions.quote) {
+ response = await fetch(`${BASE_URL}/tweets`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({
+ content: faker.lorem.sentence() + ' ' + mention,
+ quoteToTweetId: tweetId,
+ }),
+ });
+
+ if (!response.ok) {
+ console.error(`Failed to quote tweet: ${response.statusText}`);
+ }
+ }
+
+ if (actions.reply) {
+ response = await fetch(`${BASE_URL}/tweets`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({
+ content: faker.lorem.sentence() + ' ' + mention,
+ replyToTweetId: tweetId,
+ }),
+ });
+
+ if (!response.ok) {
+ console.error(`Failed to reply to tweet: ${response.statusText}`);
+ }
+ }
+ }
+}
diff --git a/e2e/utils/tweetActions.js b/e2e/utils/tweetActions.js
new file mode 100644
index 000000000..75b61c486
--- /dev/null
+++ b/e2e/utils/tweetActions.js
@@ -0,0 +1,40 @@
+import { faker } from '@faker-js/faker';
+
+const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000';
+
+export async function createReply(accessToken, tweetId) {
+ const response = await fetch(`${BASE_URL}/tweets`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({
+ content: faker.lorem.sentence(),
+ replyToTweetId: tweetId,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to create reply: ${response.statusText}`);
+ }
+
+ const res = await response.json();
+ return res.data.id;
+}
+
+export async function likeTweet(accessToken, tweetId) {
+ const response = await fetch(`${BASE_URL}/tweets/${tweetId}/like`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to like tweet: ${response.statusText}`);
+ }
+
+ return response.json();
+}
diff --git a/eslint.config.js b/eslint.config.js
index ab5540bc3..e7b9fabe4 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -13,6 +13,7 @@ module.exports = defineConfig([
it: true,
before: true,
beforeEach: true,
+ after: true,
},
},
},
diff --git a/jest.setup.js b/jest.setup.js
index b16fac748..dce36752b 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -96,6 +96,41 @@ jest.mock('expo-video', () => {
};
});
+jest.mock('@shopify/flash-list', () => {
+ const ReactActual = jest.requireActual('react');
+
+ function FlashList(props) {
+ const { data = [], renderItem, ListEmptyComponent, ListFooterComponent, keyExtractor } = props;
+
+ return ReactActual.createElement(
+ ReactActual.Fragment,
+ null,
+
+ data.length > 0 && renderItem
+ ? data.map((item, index) => {
+ const element = renderItem({ item, index });
+ if (!element) return null;
+
+ const key = keyExtractor?.(item, index) ?? index;
+ return ReactActual.cloneElement(element, { key });
+ })
+ : ListEmptyComponent
+ ? ReactActual.isValidElement(ListEmptyComponent)
+ ? ListEmptyComponent
+ : ReactActual.createElement(ListEmptyComponent)
+ : null,
+
+ ListFooterComponent
+ ? ReactActual.isValidElement(ListFooterComponent)
+ ? ListFooterComponent
+ : ReactActual.createElement(ListFooterComponent)
+ : null
+ );
+ }
+
+ return { FlashList };
+});
+
// Add global cleanup to handle timers and async operations
afterEach(() => {
// Clear all timers after each test
diff --git a/package.json b/package.json
index 7b1cefcab..c63e1ac00 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,6 @@
"expo-image": "~3.0.10",
"expo-image-picker": "~17.0.8",
"expo-linking": "~8.0.8",
- "expo-localization": "~17.0.7",
"expo-media-library": "^18.2.0",
"expo-secure-store": "~15.0.7",
"expo-splash-screen": "~31.0.10",
@@ -109,6 +108,7 @@
"@wdio/spec-reporter": "^9.20.0",
"appium": "^2.19.0",
"appium-uiautomator2-driver": "^4.2.9",
+ "babel-plugin-dynamic-import-node": "^2.3.3",
"babel-preset-expo": "^54.0.5",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e86423de4..24a84eab6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -101,9 +101,6 @@ importers:
expo-linking:
specifier: ~8.0.8
version: 8.0.8(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
- expo-localization:
- specifier: ~17.0.7
- version: 17.0.7(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0)
expo-media-library:
specifier: ^18.2.0
version: 18.2.0(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))
@@ -267,6 +264,9 @@ importers:
appium-uiautomator2-driver:
specifier: ^4.2.9
version: 4.2.9(appium@2.19.0)
+ babel-plugin-dynamic-import-node:
+ specifier: ^2.3.3
+ version: 2.3.3
babel-preset-expo:
specifier: ^54.0.5
version: 54.0.6(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-refresh@0.14.2)
@@ -3151,6 +3151,9 @@ packages:
peerDependencies:
'@babel/core': ^7.8.0
+ babel-plugin-dynamic-import-node@2.3.3:
+ resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==}
+
babel-plugin-istanbul@6.1.1:
resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==}
engines: {node: '>=8'}
@@ -4409,12 +4412,6 @@ packages:
react: '*'
react-native: '*'
- expo-localization@17.0.7:
- resolution: {integrity: sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA==}
- peerDependencies:
- expo: '*'
- react: '*'
-
expo-manifests@1.0.8:
resolution: {integrity: sha512-nA5PwU2uiUd+2nkDWf9e71AuFAtbrb330g/ecvuu52bmaXtN8J8oiilc9BDvAX0gg2fbtOaZdEdjBYopt1jdlQ==}
peerDependencies:
@@ -7241,9 +7238,6 @@ packages:
react: '*'
react-native: '*'
- rtl-detect@1.1.2:
- resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==}
-
run-async@4.0.6:
resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==}
engines: {node: '>=0.12.0'}
@@ -12280,6 +12274,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ babel-plugin-dynamic-import-node@2.3.3:
+ dependencies:
+ object.assign: 4.1.7
+
babel-plugin-istanbul@6.1.1:
dependencies:
'@babel/helper-plugin-utils': 7.27.1
@@ -13798,12 +13796,6 @@ snapshots:
- expo
- supports-color
- expo-localization@17.0.7(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0):
- dependencies:
- expo: 54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
- react: 19.1.0
- rtl-detect: 1.1.2
-
expo-manifests@1.0.8(expo@54.0.21(@babel/core@7.28.5)(graphql@16.12.0)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)):
dependencies:
'@expo/config': 12.0.10
@@ -17272,8 +17264,6 @@ snapshots:
react: 19.1.0
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
- rtl-detect@1.1.2: {}
-
run-async@4.0.6: {}
run-parallel@1.2.0:
diff --git a/src/App.tsx b/src/App.tsx
index fca01940e..7d416ef92 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -23,7 +23,6 @@ import './global.css';
import { queryClient } from '@/libs/queryClient';
import { usePushNotifications } from './hooks/notifications/usePushNotifications';
-import { useDmSse } from './hooks/useDmSse';
import { ThemeProvider, useTheme } from './hooks/useTheme';
import { navigationRef } from './navigation/navigationRef';
import RootNavigator from './navigation/RootNavigator';
@@ -81,8 +80,6 @@ export function ThemedAppContent() {
}
}, [pushToken, activeAccountId]);
- useDmSse(activeAccountId);
-
if (!appIsReady || !fontsLoaded) return null;
const initialRouteName = activeAccountId ? 'Drawer' : 'Start';
diff --git a/src/__tests__/components/TweetComposer.test.tsx b/src/__tests__/components/TweetComposer.test.tsx
index f1f3317bd..0a73288ca 100644
--- a/src/__tests__/components/TweetComposer.test.tsx
+++ b/src/__tests__/components/TweetComposer.test.tsx
@@ -41,6 +41,14 @@ jest.mock('expo-image-picker', () => ({
},
}));
+jest.mock('expo-file-system', () => ({
+ getInfoAsync: jest.fn().mockResolvedValue({ exists: true, size: 1024 * 1024 }), // 1MB mock
+}));
+
+jest.mock('expo-file-system/legacy', () => ({
+ getInfoAsync: jest.fn().mockResolvedValue({ exists: true, size: 1024 * 1024 }),
+}));
+
jest.mock('expo-video', () => {
const addListener = jest.fn(() => ({ remove: jest.fn() }));
const React = jest.requireActual('react');
@@ -131,6 +139,42 @@ const mockQueryClient = new QueryClient({
},
});
+// Shared mock tweet factory
+const createMockTweet = (overrides = {}) => ({
+ id: 'tweet-default',
+ content: 'Default tweet content',
+ author: {
+ displayName: 'Test User',
+ username: 'testuser',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
+ createdAt: new Date().toISOString(),
+ replyToTweetId: null,
+ quoteToTweetId: null,
+ quotedTweet: null,
+ replyToTweet: null,
+ repostedBy: null,
+ isRepost: false,
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 0,
+ isLiked: false,
+ isRetweeted: false,
+ media: [],
+ entities: {
+ mentions: null,
+ hashtags: null,
+ },
+ ...overrides,
+});
+
describe('TweetComposer', () => {
const renderWithProviders = (component: React.ReactElement) => {
return render(
@@ -145,27 +189,22 @@ describe('TweetComposer', () => {
);
};
- const mockTweet = {
+ const mockTweet = createMockTweet({
id: '1',
content: 'This is a mock tweet',
author: {
displayName: 'Mock User',
username: 'mockuser',
avatarUrl: 'https://example.com/avatar.png',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
- createdAt: '2025-12-08T12:00:00Z',
- replyToTweet: null,
- replyCount: 0,
- retweetCount: 0,
- likeCount: 0,
- isLiked: false,
- isRetweeted: false,
- media: [],
- entities: {
- mentions: [],
- hashtags: [],
- },
- };
+ });
beforeEach(() => {
jest.clearAllMocks();
@@ -249,7 +288,6 @@ describe('TweetComposer', () => {
mediaTypes: ['images', 'videos'],
allowsMultipleSelection: true,
selectionLimit: 4,
- allowsEditing: true,
quality: 1,
videoMaxDuration: 140,
exif: false,
@@ -527,7 +565,6 @@ describe('TweetComposer', () => {
expect(mockLaunchCamera).toHaveBeenCalledWith({
mediaTypes: ['images'],
quality: 1,
- allowsEditing: true,
exif: false,
});
});
@@ -750,10 +787,15 @@ describe('TweetComposer', () => {
it('successfully posts tweet with text only', async () => {
const mockPostTweet = jest.mocked(tweetService.postTweet);
+ const postedTweet = createMockTweet({
+ id: 'tweet-123',
+ content: 'Test tweet content',
+ });
+
mockPostTweet.mockResolvedValue({
success: true,
message: 'Tweet posted successfully',
- data: { id: 'tweet-123' },
+ data: postedTweet,
});
const onClose = jest.fn();
@@ -765,12 +807,10 @@ describe('TweetComposer', () => {
const postButton = getByTestId('post-button');
fireEvent.press(postButton);
- // Composer closes after successful posting
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
- // Posting happens
await waitFor(() => {
expect(mockPostTweet).toHaveBeenCalledWith({
content: 'Test tweet content',
@@ -780,6 +820,9 @@ describe('TweetComposer', () => {
});
it('handles posting error gracefully', async () => {
+ // Mock console.error to suppress expected error logs
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
const mockPostTweet = jest.mocked(tweetService.postTweet);
mockPostTweet.mockRejectedValue(new Error('Network error'));
@@ -792,18 +835,31 @@ describe('TweetComposer', () => {
const postButton = getByTestId('post-button');
fireEvent.press(postButton);
- // Posting fails
await waitFor(() => {
expect(mockPostTweet).toHaveBeenCalled();
});
- // Composer stays open on error
expect(onClose).not.toHaveBeenCalled();
+
+ consoleErrorSpy.mockRestore();
});
it('uploads media and posts tweet with media IDs', async () => {
const mockUploadImage = jest.mocked(tweetService.uploadImage);
const mockPostTweet = jest.mocked(tweetService.postTweet);
+ const postedTweet = createMockTweet({
+ id: 'tweet-123',
+ content: 'Test tweet with media',
+ media: [
+ {
+ type: 'IMAGE' as const,
+ url: 'https://example.com/media-123.jpg',
+ altText: '',
+ width: 800,
+ height: 600,
+ },
+ ],
+ });
mockUploadImage.mockResolvedValue({
success: true,
@@ -816,7 +872,7 @@ describe('TweetComposer', () => {
mockPostTweet.mockResolvedValue({
success: true,
message: 'Tweet posted successfully',
- data: { id: 'tweet-123' },
+ data: postedTweet,
});
const onClose = jest.fn();
@@ -824,7 +880,6 @@ describe('TweetComposer', () => {
);
- // Select an asset
const mediaButtons = getAllByTestId(/^media-image-/);
fireEvent.press(mediaButtons[0]);
@@ -846,6 +901,11 @@ describe('TweetComposer', () => {
it('disables post button when submitting', async () => {
const mockPostTweet = jest.mocked(tweetService.postTweet);
+ const postedTweet = createMockTweet({
+ id: 'tweet-123',
+ content: 'Test tweet',
+ });
+
mockPostTweet.mockImplementation(
() =>
new Promise((resolve) =>
@@ -854,7 +914,7 @@ describe('TweetComposer', () => {
resolve({
success: true,
message: 'Tweet posted successfully',
- data: { id: 'tweet-123' },
+ data: postedTweet,
}),
100
)
@@ -876,8 +936,9 @@ describe('TweetComposer', () => {
});
describe('Quote Tweet Functionality', () => {
- const mockQuotedTweet = {
+ const mockQuotedTweet = createMockTweet({
id: 'quoted-123',
+ content: 'Original tweet content',
author: {
username: 'quoted_user',
displayName: 'Quoted User',
@@ -890,16 +951,9 @@ describe('TweetComposer', () => {
follower: false,
},
},
- content: 'Original tweet content',
- createdAt: new Date().toISOString(),
- replyCount: 0,
retweetCount: 5,
likeCount: 10,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- };
+ });
it('renders quoted tweet when quotedTweet prop is provided', () => {
const { getByText } = renderWithProviders(
@@ -913,10 +967,17 @@ describe('TweetComposer', () => {
it('includes quotedTweetId when posting with quoted tweet', async () => {
const mockPostTweet = jest.mocked(tweetService.postTweet);
+ const postedTweet = createMockTweet({
+ id: 'tweet-456',
+ content: 'My quote comment',
+ quoteToTweetId: 'quoted-123',
+ quotedTweet: mockQuotedTweet,
+ });
+
mockPostTweet.mockResolvedValue({
success: true,
message: 'Tweet posted successfully',
- data: { id: 'tweet-456' },
+ data: postedTweet,
});
const onClose = jest.fn();
@@ -941,11 +1002,6 @@ describe('TweetComposer', () => {
it('requires text or media even with quoted tweet', async () => {
const mockPostTweet = jest.mocked(tweetService.postTweet);
- mockPostTweet.mockResolvedValue({
- success: true,
- message: 'Tweet posted successfully',
- data: { id: 'tweet-789' },
- });
const onClose = jest.fn();
const { getByTestId } = renderWithProviders(
@@ -954,10 +1010,8 @@ describe('TweetComposer', () => {
const postButton = getByTestId('post-button');
- // Post button should be disabled without text or media
expect(postButton.props.accessibilityState?.disabled).toBe(true);
- // Button press should not trigger post when disabled
fireEvent.press(postButton);
expect(mockPostTweet).not.toHaveBeenCalled();
@@ -965,10 +1019,17 @@ describe('TweetComposer', () => {
it('calls onSuccess callback after successful quote tweet post', async () => {
const mockPostTweet = jest.mocked(tweetService.postTweet);
+ const postedTweet = createMockTweet({
+ id: 'tweet-success',
+ content: 'My quote comment',
+ quoteToTweetId: 'quoted-123',
+ quotedTweet: mockQuotedTweet,
+ });
+
mockPostTweet.mockResolvedValue({
success: true,
message: 'Tweet posted successfully',
- data: { id: 'tweet-success' },
+ data: postedTweet,
});
const onSuccess = jest.fn();
@@ -1009,7 +1070,6 @@ describe('TweetComposer', () => {
/>
);
- // Add some text to enable the post button
const textInput = getByTestId('text-input');
fireEvent.changeText(textInput, 'This will fail');
@@ -1027,6 +1087,21 @@ describe('TweetComposer', () => {
it('can post quote tweet with media attachments', async () => {
const mockUploadImage = jest.mocked(tweetService.uploadImage);
const mockPostTweet = jest.mocked(tweetService.postTweet);
+ const postedTweet = createMockTweet({
+ id: 'tweet-with-media',
+ content: 'Quote with image',
+ quoteToTweetId: 'quoted-123',
+ quotedTweet: mockQuotedTweet,
+ media: [
+ {
+ type: 'IMAGE' as const,
+ url: 'https://example.com/media-456.jpg',
+ altText: '',
+ width: 800,
+ height: 600,
+ },
+ ],
+ });
mockUploadImage.mockResolvedValue({
success: true,
@@ -1039,14 +1114,13 @@ describe('TweetComposer', () => {
mockPostTweet.mockResolvedValue({
success: true,
message: 'Tweet posted successfully',
- data: { id: 'tweet-with-media' },
+ data: postedTweet,
});
const { getByTestId, getAllByTestId } = renderWithProviders(
{}} quotedTweet={mockQuotedTweet} />
);
- // Select an asset
const mediaButtons = getAllByTestId(/^media-image-/);
fireEvent.press(mediaButtons[0]);
@@ -1066,4 +1140,79 @@ describe('TweetComposer', () => {
});
});
});
+
+ it('handles navigation to author profile', () => {
+ const onPressAuthor = jest.fn();
+ const onClose = jest.fn();
+
+ const mockReplyToTweet = {
+ author: { username: 'author', displayName: 'Author', avatarUrl: null },
+ createdAt: new Date().toISOString(),
+ content: 'Original tweet',
+ };
+
+ const { getByTestId } = renderWithProviders(
+
+ );
+
+ const authorLink = getByTestId('replying-to-username');
+ fireEvent.press(authorLink);
+ expect(onPressAuthor).toHaveBeenCalledWith('author');
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('alerts when camera permission is denied', async () => {
+ const mockRequestPermission = ImagePicker.requestCameraPermissionsAsync as jest.Mock;
+ mockRequestPermission.mockResolvedValueOnce({ status: 'denied' });
+ const mockAlert = jest.spyOn(Alert, 'alert');
+
+ const { getByTestId } = renderWithProviders( {}} />);
+ const cameraIcon = getByTestId('camera-icon');
+
+ fireEvent.press(cameraIcon);
+
+ await waitFor(() => {
+ expect(mockAlert).toHaveBeenCalledWith('Permission needed', expect.any(String));
+ });
+ mockAlert.mockRestore();
+ });
+
+ it('alerts when file is too large', async () => {
+ const mockLaunchImageLibrary = ImagePicker.launchImageLibraryAsync as jest.Mock;
+ mockLaunchImageLibrary.mockResolvedValue({
+ canceled: false,
+ assets: [
+ {
+ uri: 'file://large-image.jpg',
+ fileName: 'large-image.jpg',
+ width: 1000,
+ height: 1000,
+ },
+ ],
+ });
+
+ const mockGetInfo = require('expo-file-system/legacy').getInfoAsync;
+ mockGetInfo.mockResolvedValue({ exists: true, size: 10 * 1024 * 1024 }); // 10MB > 5MB limit
+
+ const mockAlert = jest.spyOn(Alert, 'alert');
+
+ const { getByTestId } = renderWithProviders( {}} />);
+ const pictureIcon = getByTestId('media-icon');
+
+ fireEvent.press(pictureIcon);
+
+ await waitFor(() => {
+ expect(mockAlert).toHaveBeenCalledWith(
+ 'File Too Large',
+ expect.stringContaining('exceed size limits')
+ );
+ });
+ mockAlert.mockRestore();
+ });
});
diff --git a/src/__tests__/components/messages/ChatHeader.test.tsx b/src/__tests__/components/messages/ChatHeader.test.tsx
new file mode 100644
index 000000000..f28babdba
--- /dev/null
+++ b/src/__tests__/components/messages/ChatHeader.test.tsx
@@ -0,0 +1,97 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+import { fireEvent, render } from '@testing-library/react-native';
+
+import ChatHeader from '@/components/messages/ChatHeader';
+
+jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'dark' as const }) }));
+
+const mockNavigate = jest.fn();
+const mockGoBack = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ goBack: mockGoBack,
+ }),
+}));
+
+describe('ChatHeader', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders back button', () => {
+ const { UNSAFE_getByProps } = render(
+
+ );
+
+ const backIcon = UNSAFE_getByProps({ name: 'arrow-back' });
+ expect(backIcon).toBeTruthy();
+ });
+
+ it('calls goBack when back button is pressed', () => {
+ const { UNSAFE_getByProps } = render(
+
+ );
+
+ const backButton = UNSAFE_getByProps({ name: 'arrow-back' }).parent?.parent;
+ fireEvent.press(backButton);
+
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders participant display name', () => {
+ const { getByText } = render(
+
+ );
+
+ expect(getByText('Bob Smith')).toBeTruthy();
+ });
+
+ it('renders participant avatar when provided', () => {
+ const { UNSAFE_getByType } = render(
+
+ );
+
+ const { Image } = require('react-native');
+ const image = UNSAFE_getByType(Image);
+ expect(image.props.source.uri).toBe('https://example.com/avatar.jpg');
+ });
+
+ it('navigates to user profile when participant is pressed', () => {
+ const { getByText } = render(
+
+ );
+
+ const participantButton = getByText('Dave').parent?.parent?.parent;
+ fireEvent.press(participantButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('Profile', {
+ screen: 'UserProfile',
+ params: { username: 'dave' },
+ });
+ });
+
+ it('does not render participant info when null', () => {
+ const { UNSAFE_queryByType } = render( );
+
+ const { Image } = require('react-native');
+ expect(UNSAFE_queryByType(Image)).toBeNull();
+ });
+
+ it('handles participant without avatarUrl', () => {
+ const { UNSAFE_getByType } = render(
+
+ );
+
+ const { Image } = require('react-native');
+ const image = UNSAFE_getByType(Image);
+ expect(image.props.source.uri).toBeUndefined();
+ });
+});
diff --git a/src/__tests__/components/messages/MessageInput.test.tsx b/src/__tests__/components/messages/MessageInput.test.tsx
index 38b6f14ea..684bc907f 100644
--- a/src/__tests__/components/messages/MessageInput.test.tsx
+++ b/src/__tests__/components/messages/MessageInput.test.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
import { fireEvent, render } from '@testing-library/react-native';
import MessageInput from '@/components/messages/MessageInput';
@@ -5,6 +6,10 @@ import MessageInput from '@/components/messages/MessageInput';
jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'dark' as const }) }));
jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) }));
+jest.mock('expo-image-picker', () => ({
+ launchImageLibraryAsync: jest.fn(),
+}));
+
describe('MessageInput', () => {
it('does not render send button for empty value', () => {
const { queryByTestId } = render(
@@ -47,4 +52,188 @@ describe('MessageInput', () => {
);
expect(queryByTestId('send-button')).toBeNull();
});
+
+ it('shows send button when media is present without text', () => {
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId('send-button')).toBeTruthy();
+ });
+
+ it('displays media preview with remove button', () => {
+ const { getByLabelText, UNSAFE_getByType } = render(
+
+ );
+
+ const removeButton = getByLabelText('Remove image');
+ expect(removeButton).toBeTruthy();
+
+ const { Image } = require('react-native');
+ const image = UNSAFE_getByType(Image);
+ expect(image.props.source.uri).toBe('file://test-photo.jpg');
+ });
+
+ it('calls onMediaSelect with null when media is removed', () => {
+ const onMediaSelect = jest.fn();
+ const { getByLabelText } = render(
+
+ );
+
+ const removeButton = getByLabelText('Remove image');
+ fireEvent.press(removeButton);
+
+ expect(onMediaSelect).toHaveBeenCalledWith(null);
+ });
+
+ it('opens image picker when image button is pressed', async () => {
+ const ImagePicker = require('expo-image-picker');
+ const onMediaSelect = jest.fn();
+
+ ImagePicker.launchImageLibraryAsync.mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file://selected-image.jpg', duration: undefined }],
+ });
+
+ const { UNSAFE_getByProps } = render(
+
+ );
+
+ const imageButton = UNSAFE_getByProps({ name: 'image-outline' }).parent?.parent;
+ await fireEvent.press(imageButton);
+
+ expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
+ mediaTypes: ['images', 'videos'],
+ allowsMultipleSelection: false,
+ allowsEditing: false,
+ quality: 1,
+ videoMaxDuration: 140,
+ });
+
+ expect(onMediaSelect).toHaveBeenCalledWith('file://selected-image.jpg', 'photo');
+ });
+
+ it('detects video type when asset has duration', async () => {
+ const ImagePicker = require('expo-image-picker');
+ const onMediaSelect = jest.fn();
+
+ ImagePicker.launchImageLibraryAsync.mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file://selected-video.mp4', duration: 30 }],
+ });
+
+ const { UNSAFE_getByProps } = render(
+
+ );
+
+ const imageButton = UNSAFE_getByProps({ name: 'image-outline' }).parent?.parent;
+ await fireEvent.press(imageButton);
+
+ expect(onMediaSelect).toHaveBeenCalledWith('file://selected-video.mp4', 'video');
+ });
+
+ it('does not call onMediaSelect when picker is canceled', async () => {
+ const ImagePicker = require('expo-image-picker');
+ const onMediaSelect = jest.fn();
+
+ ImagePicker.launchImageLibraryAsync.mockResolvedValue({
+ canceled: true,
+ assets: [],
+ });
+
+ const { UNSAFE_getByProps } = render(
+
+ );
+
+ const imageButton = UNSAFE_getByProps({ name: 'image-outline' }).parent?.parent;
+ await fireEvent.press(imageButton);
+
+ expect(onMediaSelect).not.toHaveBeenCalled();
+ });
+
+ it('respects maxLength of 1000 characters', () => {
+ const { UNSAFE_getByType } = render(
+
+ );
+
+ const { TextInput } = require('react-native');
+ const input = UNSAFE_getByType(TextInput);
+ expect(input.props.maxLength).toBe(1000);
+ });
+
+ it('has multiline enabled for TextInput', () => {
+ const { UNSAFE_getByType } = render(
+
+ );
+
+ const { TextInput } = require('react-native');
+ const input = UNSAFE_getByType(TextInput);
+ expect(input.props.multiline).toBe(true);
+ });
+
+ it('has correct style constraints for height', () => {
+ const { UNSAFE_getByType } = render(
+
+ );
+
+ const { TextInput, StyleSheet } = require('react-native');
+ const input = UNSAFE_getByType(TextInput);
+
+ const flattenedStyle = StyleSheet.flatten(input.props.style);
+ expect(flattenedStyle.maxHeight).toBe(100);
+ expect(flattenedStyle.minHeight).toBe(20);
+ });
});
diff --git a/src/__tests__/components/messages/MessageItem.test.tsx b/src/__tests__/components/messages/MessageItem.test.tsx
index b2752de2e..6ea0f8a80 100644
--- a/src/__tests__/components/messages/MessageItem.test.tsx
+++ b/src/__tests__/components/messages/MessageItem.test.tsx
@@ -1,16 +1,23 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable react/display-name */
-import { Linking } from 'react-native';
+import { Image, Linking, Pressable, TouchableOpacity } from 'react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react-native';
import MessageItem from '@/components/messages/MessageItem';
+import { MessageOptionsModal } from '@/components/messages/MessageOptionsModal';
+import { ReactionDetailsDrawer } from '@/components/messages/ReactionDetailsDrawer';
+import { ReactionPicker } from '@/components/messages/ReactionPicker';
jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'dark' as const }) }));
+const mockNavigate = jest.fn();
+
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
- navigate: jest.fn(),
+ navigate: mockNavigate,
goBack: jest.fn(),
}),
}));
@@ -134,4 +141,716 @@ describe('MessageItem', () => {
expect(openSpy).toHaveBeenCalledWith('https://raven.dev');
});
+
+ it('displays same reaction count when both users react with same emoji', () => {
+ const msg = {
+ ...base,
+ id: 'same-react',
+ content: 'Test message',
+ reactions: {
+ sender: {
+ username: 'alice',
+ displayName: 'Alice',
+ avatarUrl: null,
+ reaction: '❤️',
+ reactedAt: new Date().toISOString(),
+ },
+ receiver: {
+ username: 'bob',
+ displayName: 'Bob',
+ avatarUrl: null,
+ reaction: '❤️',
+ reactedAt: new Date().toISOString(),
+ },
+ },
+ };
+
+ const { getByText } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(getByText('❤️')).toBeTruthy();
+ expect(getByText('2')).toBeTruthy();
+ });
+
+ it('displays different reactions when users react with different emojis', () => {
+ const msg = {
+ ...base,
+ id: 'diff-react',
+ content: 'Test message',
+ reactions: {
+ sender: {
+ username: 'alice',
+ displayName: 'Alice',
+ avatarUrl: null,
+ reaction: '❤️',
+ reactedAt: new Date().toISOString(),
+ },
+ receiver: {
+ username: 'bob',
+ displayName: 'Bob',
+ avatarUrl: null,
+ reaction: '😂',
+ reactedAt: new Date().toISOString(),
+ },
+ },
+ };
+
+ const { getByText } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(getByText('❤️')).toBeTruthy();
+ expect(getByText('😂')).toBeTruthy();
+ });
+
+ it('shows border on current user reaction when different reactions', () => {
+ const msg = {
+ ...base,
+ id: 'border-react',
+ isMine: true,
+ content: 'Test message',
+ reactions: {
+ sender: {
+ username: 'testuser',
+ displayName: 'Test User',
+ avatarUrl: null,
+ reaction: '❤️',
+ reactedAt: new Date().toISOString(),
+ },
+ receiver: {
+ username: 'bob',
+ displayName: 'Bob',
+ avatarUrl: null,
+ reaction: '😂',
+ reactedAt: new Date().toISOString(),
+ },
+ },
+ };
+
+ const { getByText } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ const heartReaction = getByText('❤️').parent;
+ // Current user's reaction should have border styling
+ expect(heartReaction?.props.style).toBeTruthy();
+ });
+
+ it('displays image media with correct dimensions', () => {
+ const msg = {
+ ...base,
+ id: 'img-msg',
+ content: '',
+ mediaUrl: 'https://example.com/image.jpg',
+ };
+
+ const { UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ const image = UNSAFE_getByType(Image);
+ expect(image.props.source.uri).toBe('https://example.com/image.jpg');
+ expect(image.props.style).toMatchObject({
+ borderRadius: 16,
+ });
+ });
+
+ it('displays video media with play icon overlay', () => {
+ const msg = {
+ ...base,
+ id: 'vid-msg',
+ content: '',
+ mediaUrl: 'https://example.com/video.mp4',
+ };
+
+ const { UNSAFE_getByProps } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ const playIcon = UNSAFE_getByProps({ name: 'play' });
+ expect(playIcon.props.size).toBe(32);
+ expect(playIcon.props.color).toBe('white');
+ });
+
+ it('navigates to MESSAGE_IMAGE screen when image is pressed', () => {
+ const msg = {
+ ...base,
+ id: 'img-press',
+ content: 'Check this',
+ mediaUrl: 'https://example.com/photo.jpg',
+ };
+
+ const { getByTestId, UNSAFE_queryAllByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ // Check if image is rendered first
+ const image = UNSAFE_queryAllByType(Image)[0];
+ expect(image).toBeTruthy();
+
+ // The image is wrapped in a Pressable
+ const pressables = UNSAFE_queryAllByType(Pressable);
+ if (pressables.length > 0) {
+ pressables[0].props.onPress();
+
+ expect(mockNavigate).toHaveBeenCalledWith('MESSAGE_IMAGE', {
+ uri: 'https://example.com/photo.jpg',
+ });
+ }
+ });
+
+ it('displays video media with correct structure', () => {
+ const msg = {
+ ...base,
+ id: 'vid-press',
+ content: 'Watch this',
+ mediaUrl: 'https://example.com/video.mp4',
+ };
+
+ const { getByTestId } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ // Verify the message is rendered
+ expect(getByTestId('message-vid-press')).toBeTruthy();
+ });
+
+ it('shows "Uploading" status for optimistic message with media', () => {
+ const msg = {
+ ...base,
+ id: 'optimistic-media-123',
+ content: '',
+ mediaUrl: 'file://local-video.mp4',
+ };
+
+ const { getByText } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(getByText(/Uploading/)).toBeTruthy();
+ });
+
+ it('calls onDelete when delete is confirmed in options modal', () => {
+ const { act } = require('react');
+ const onDelete = jest.fn();
+ const msg = {
+ ...base,
+ id: 'delete-me',
+ content: 'Test message',
+ };
+
+ const { UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ // Simulate long press to open options modal
+ const touchable = UNSAFE_getByType(TouchableOpacity);
+ act(() => {
+ touchable.props.onLongPress();
+ });
+
+ // Find and trigger the delete handler through the MessageOptionsModal
+ // (The modal receives onDelete prop which should call our onDelete)
+ const modal = UNSAFE_getByType(MessageOptionsModal);
+ modal.props.onDelete();
+
+ expect(onDelete).toHaveBeenCalledWith('delete-me');
+ });
+
+ it('does not show options modal on long press for optimistic messages', () => {
+ const msg = {
+ ...base,
+ id: 'optimistic-no-options',
+ content: 'Sending...',
+ };
+
+ const { UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ const touchable = UNSAFE_getByType(TouchableOpacity);
+ expect(touchable.props.disabled).toBe(true);
+ });
+
+ it('opens reaction details drawer when reactions are pressed', () => {
+ const msg = {
+ ...base,
+ id: 'react-details',
+ content: 'Test message',
+ reactions: {
+ sender: {
+ username: 'alice',
+ displayName: 'Alice',
+ avatarUrl: null,
+ reaction: '❤️',
+ reactedAt: new Date().toISOString(),
+ },
+ receiver: {
+ username: 'bob',
+ displayName: 'Bob',
+ avatarUrl: null,
+ reaction: '😂',
+ reactedAt: new Date().toISOString(),
+ },
+ },
+ };
+
+ const { getByText, UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ // Press on the reactions area
+ const reactionsPressable = getByText('❤️').parent?.parent;
+ if (reactionsPressable?.props.onPress) {
+ reactionsPressable.props.onPress();
+ }
+
+ // ReactionDetailsDrawer should receive visible=true
+ const drawer = UNSAFE_getByType(ReactionDetailsDrawer);
+ // Initial state before interaction would have visible=false
+ });
+
+ it('opens reaction picker on double tap', async () => {
+ jest.useFakeTimers();
+
+ const msg = {
+ ...base,
+ id: 'double-tap-msg',
+ content: 'Double tap me',
+ };
+
+ const { UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ const touchable = UNSAFE_getByType(TouchableOpacity);
+
+ // First tap
+ touchable.props.onPress();
+
+ // Second tap within 300ms
+ touchable.props.onPress();
+
+ // ReactionPicker should be triggered
+ const picker = UNSAFE_getByType(ReactionPicker);
+ expect(picker).toBeTruthy();
+
+ jest.useRealTimers();
+ });
+
+ it('opens reaction picker from options modal', () => {
+ const { act } = require('react');
+ const msg = {
+ ...base,
+ id: 'react-from-modal',
+ content: 'Message',
+ };
+
+ const { UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ // Long press to open options
+ const touchable = UNSAFE_getByType(TouchableOpacity);
+ act(() => {
+ touchable.props.onLongPress();
+ });
+
+ // Trigger react option
+ const modal = UNSAFE_getByType(MessageOptionsModal);
+ modal.props.onReact();
+
+ // ReactionPicker should be rendered
+ const picker = UNSAFE_getByType(ReactionPicker);
+ expect(picker).toBeTruthy();
+ });
+
+ it('calls handleSelectEmoji when emoji is selected', () => {
+ const msg = {
+ ...base,
+ id: 'select-emoji',
+ content: 'Test',
+ };
+
+ const { UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ const picker = UNSAFE_getByType(ReactionPicker);
+ picker.props.onSelectEmoji('👍');
+
+ // Should call sendReaction internally
+ expect(picker.props.onSelectEmoji).toBeTruthy();
+ });
+
+ it('handles undo reaction when user has reacted', () => {
+ const { act } = require('react');
+ const msg = {
+ ...base,
+ id: 'undo-react',
+ content: 'Test',
+ reactions: {
+ sender: {
+ username: 'testuser',
+ displayName: 'Test',
+ avatarUrl: null,
+ reaction: '❤️',
+ reactedAt: new Date().toISOString(),
+ },
+ receiver: {
+ username: 'other',
+ displayName: 'Other',
+ avatarUrl: null,
+ reaction: null,
+ reactedAt: null,
+ },
+ },
+ };
+
+ const { UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ const drawer = UNSAFE_getByType(ReactionDetailsDrawer);
+ act(() => {
+ drawer.props.onUndoReaction();
+ });
+
+ // Should call sendReaction to toggle off
+ expect(drawer.props.onUndoReaction).toBeTruthy();
+ });
+
+ it('closes modals when onClose is called', () => {
+ const { act } = require('react');
+ const msg = {
+ ...base,
+ id: 'close-modals',
+ content: 'Test',
+ };
+
+ const { UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ const optionsModal = UNSAFE_getByType(MessageOptionsModal);
+ const reactionPicker = UNSAFE_getByType(ReactionPicker);
+ const reactionDrawer = UNSAFE_getByType(ReactionDetailsDrawer);
+
+ act(() => {
+ optionsModal.props.onClose();
+ reactionPicker.props.onClose();
+ reactionDrawer.props.onClose();
+ });
+
+ // All modals should have onClose handlers
+ expect(optionsModal.props.onClose).toBeTruthy();
+ expect(reactionPicker.props.onClose).toBeTruthy();
+ expect(reactionDrawer.props.onClose).toBeTruthy();
+ });
+
+ it('handles image load event and calculates dimensions', () => {
+ const { act } = require('react');
+ const msg = {
+ ...base,
+ id: 'image-load',
+ content: 'Photo',
+ mediaUrl: 'https://example.com/large-image.jpg',
+ };
+
+ const { UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ const image = UNSAFE_getByType(Image);
+
+ // Simulate loading a large image
+ const loadEvent = {
+ nativeEvent: {
+ source: {
+ width: 800,
+ height: 600,
+ },
+ },
+ };
+
+ if (image.props.onLoad) {
+ act(() => {
+ image.props.onLoad(loadEvent);
+ });
+ }
+
+ // Image should be resized to fit maxWidth/maxHeight
+ expect(image.props.onLoad).toBeTruthy();
+ });
+
+ it('handles image load for small images without resizing', () => {
+ const { act } = require('react');
+ const msg = {
+ ...base,
+ id: 'small-image',
+ content: 'Small photo',
+ mediaUrl: 'https://example.com/small.jpg',
+ };
+
+ const { UNSAFE_getByType } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ const image = UNSAFE_getByType(Image);
+
+ // Simulate loading a small image
+ const loadEvent = {
+ nativeEvent: {
+ source: {
+ width: 200,
+ height: 150,
+ },
+ },
+ };
+
+ if (image.props.onLoad) {
+ act(() => {
+ image.props.onLoad(loadEvent);
+ });
+ }
+
+ expect(image.props.onLoad).toBeTruthy();
+ });
+
+ it('identifies video URLs correctly', () => {
+ const mp4Msg = {
+ ...base,
+ id: 'mp4-video',
+ content: 'MP4 video',
+ mediaUrl: 'https://example.com/video.mp4',
+ };
+
+ const movMsg = {
+ ...base,
+ id: 'mov-video',
+ content: 'MOV video',
+ mediaUrl: 'https://example.com/video.mov',
+ };
+
+ const videoMsg = {
+ ...base,
+ id: 'video-url',
+ content: 'Video',
+ mediaUrl: 'https://cdn.example.com/videos/clip.m3u8',
+ };
+
+ const { rerender } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ // All should be recognized as videos and render play icon
+ expect(true).toBeTruthy();
+ });
+
+ it('does not navigate to IMAGE screen for video URLs', () => {
+ const msg = {
+ ...base,
+ id: 'video-not-image',
+ content: 'Video',
+ mediaUrl: 'https://example.com/clip.mp4',
+ };
+
+ render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ // Video URLs should not trigger image navigation
+ // handleImagePress checks isVideo before navigating
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('handles empty content with only media', () => {
+ const msg = {
+ ...base,
+ id: 'media-only',
+ content: '',
+ mediaUrl: 'https://example.com/image.jpg',
+ };
+
+ const { getByTestId } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ expect(getByTestId('message-media-only')).toBeTruthy();
+ });
+
+ it('handles message without currentUser gracefully', () => {
+ jest.mock('@/stores/userStore', () => ({
+ useUserStore: () => null,
+ }));
+
+ const msg = {
+ ...base,
+ id: 'no-user',
+ content: 'Test',
+ };
+
+ const { queryByText } = render(
+ ,
+ { wrapper: createWrapper() }
+ );
+
+ // Should still render message even without currentUser
+ expect(queryByText('Test')).toBeTruthy();
+ });
});
diff --git a/src/__tests__/components/messages/MessageOptionsModal.test.tsx b/src/__tests__/components/messages/MessageOptionsModal.test.tsx
new file mode 100644
index 000000000..d0ffb4061
--- /dev/null
+++ b/src/__tests__/components/messages/MessageOptionsModal.test.tsx
@@ -0,0 +1,100 @@
+import { fireEvent, render } from '@testing-library/react-native';
+
+import { MessageOptionsModal } from '@/components/messages/MessageOptionsModal';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'dark' as const }),
+}));
+
+jest.mock('@/utils/colorTheme', () => ({
+ colors: {
+ dark: {
+ dialogBackground: '#111',
+ border: '#333',
+ primary: '#4a90e2',
+ destructive: '#ff4d4d',
+ foreground: '#fff',
+ mutedForeground: '#aaa',
+ },
+ },
+}));
+
+jest.mock('@expo/vector-icons', () => ({
+ Ionicons: 'Ionicons',
+}));
+
+describe('MessageOptionsModal', () => {
+ let onClose: jest.Mock;
+ let onDelete: jest.Mock;
+ let onReact: jest.Mock;
+
+ beforeEach(() => {
+ onClose = jest.fn();
+ onDelete = jest.fn();
+ onReact = jest.fn();
+ });
+
+ it('calls onClose when overlay is pressed', () => {
+ const { getByTestId } = render(
+
+ );
+
+ fireEvent.press(getByTestId('modal-overlay'));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens confirmation modal after pressing delete', () => {
+ const { getByText } = render(
+
+ );
+
+ fireEvent.press(getByText('Delete message for you'));
+
+ expect(getByText('Delete Message?')).toBeTruthy();
+ });
+
+ it('closes confirmation modal when overlay is pressed', () => {
+ const { getByText, getByTestId, queryByText } = render(
+
+ );
+
+ fireEvent.press(getByText('Delete message for you'));
+
+ fireEvent.press(getByTestId('confirm-overlay'));
+
+ expect(queryByText('Delete Message?')).toBeNull();
+ });
+
+ it('clicking Cancel closes confirmation modal', () => {
+ const { getByText, queryByText } = render(
+
+ );
+
+ fireEvent.press(getByText('Delete message for you'));
+ fireEvent.press(getByText('Cancel'));
+
+ expect(queryByText('Delete Message?')).toBeNull();
+ });
+
+ it('clicking Delete calls onDelete', () => {
+ const { getByText } = render(
+
+ );
+
+ fireEvent.press(getByText('Delete message for you'));
+ fireEvent.press(getByText('Delete'));
+
+ expect(onDelete).toHaveBeenCalledTimes(1);
+ });
+
+ it('pressing "Add a reaction" calls onReact and onClose', () => {
+ const { getByText } = render(
+
+ );
+
+ fireEvent.press(getByText('Add a reaction'));
+
+ expect(onReact).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/__tests__/components/messages/NewMessageComposer.test.tsx b/src/__tests__/components/messages/NewMessageComposer.test.tsx
index 5cae48af1..d95f67269 100644
--- a/src/__tests__/components/messages/NewMessageComposer.test.tsx
+++ b/src/__tests__/components/messages/NewMessageComposer.test.tsx
@@ -121,6 +121,7 @@ describe('NewMessageComposer', () => {
avatarUrl: 'https://avatar/bob.png',
bannerUrl: 'https://banner/bob.png',
bio: 'Can fix it.',
+ bioEntities: null,
relationship: {
following: false,
follower: false,
@@ -134,6 +135,7 @@ describe('NewMessageComposer', () => {
avatarUrl: 'https://avatar/carol.png',
bannerUrl: 'https://banner/carol.png',
bio: null,
+ bioEntities: null,
relationship: {
following: false,
follower: false,
@@ -278,6 +280,7 @@ describe('NewMessageComposer', () => {
displayName: 'Missing Id',
avatarUrl: null as unknown as string,
bannerUrl: null,
+ bioEntities: null,
bio: null,
relationship: {
following: false,
@@ -324,6 +327,7 @@ describe('NewMessageComposer', () => {
displayName: 'Ahmed',
avatarUrl: undefined as unknown as string,
bannerUrl: null,
+ bioEntities: null,
bio: 'Building Raven.',
relationship: {
following: false,
diff --git a/src/__tests__/components/messages/ReactionDetailsDrawer.test.tsx b/src/__tests__/components/messages/ReactionDetailsDrawer.test.tsx
new file mode 100644
index 000000000..8d2629577
--- /dev/null
+++ b/src/__tests__/components/messages/ReactionDetailsDrawer.test.tsx
@@ -0,0 +1,130 @@
+/* eslint-disable react/display-name */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { fireEvent, render } from '@testing-library/react-native';
+
+import { ReactionDetailsDrawer } from '@/components/messages/ReactionDetailsDrawer';
+
+jest.mock('react-native-animated-bottom-drawer', () => {
+ return ({ children }: any) => <>{children}>;
+});
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({
+ theme: 'light',
+ }),
+}));
+
+jest.mock('@/utils/colorTheme', () => ({
+ colors: {
+ light: {
+ background: '#fff',
+ primary: '#00f',
+ },
+ },
+}));
+
+jest.mock('@/stores/userStore', () => ({
+ useUserStore: (selector: any) =>
+ selector({
+ user: {
+ username: 'currentUser',
+ displayName: 'Current User',
+ },
+ }),
+}));
+
+const senderReaction = {
+ username: 'currentUser',
+ displayName: 'Current User',
+ avatarUrl: null,
+ reaction: '😂',
+ reactedAt: new Date().toISOString(),
+};
+
+const receiverReaction = {
+ username: 'otherUser',
+ displayName: 'Other User',
+ avatarUrl: null,
+ reaction: '🔥',
+ reactedAt: new Date().toISOString(),
+};
+
+describe('ReactionDetailsDrawer', () => {
+ const baseProps = {
+ visible: true,
+ onClose: jest.fn(),
+ onUndoReaction: jest.fn(),
+ senderReaction: null,
+ receiverReaction: null,
+ };
+
+ beforeEach(() => jest.clearAllMocks());
+
+ it('does not render when visible=false', () => {
+ const { queryByText } = render( );
+
+ expect(queryByText('Reactions')).toBeNull();
+ });
+
+ it('renders current user reaction first', () => {
+ const { getByText } = render(
+
+ );
+
+ expect(getByText('😂')).toBeTruthy();
+
+ expect(getByText('Current User')).toBeTruthy();
+ });
+
+ it('renders other user reaction second', () => {
+ const { getByText } = render(
+
+ );
+
+ expect(getByText('🔥')).toBeTruthy();
+ expect(getByText('Other User')).toBeTruthy();
+ });
+
+ it('calls undo + close on pressing Undo', () => {
+ const { getByText } = render(
+
+ );
+
+ fireEvent.press(getByText('Undo'));
+
+ expect(baseProps.onUndoReaction).toHaveBeenCalled();
+ expect(baseProps.onClose).toHaveBeenCalled();
+ });
+
+ it('renders empty state when no reactions exist', () => {
+ const { getByText } = render(
+
+ );
+
+ expect(getByText('No reactions yet')).toBeTruthy();
+ });
+
+ it('renders only other user reaction if the current user did not react', () => {
+ const { getByText } = render(
+
+ );
+
+ expect(getByText('🔥')).toBeTruthy();
+ expect(getByText('Other User')).toBeTruthy();
+
+ expect(() => getByText('Undo')).toThrow();
+ });
+});
diff --git a/src/__tests__/components/messages/ReactionPicker.test.tsx b/src/__tests__/components/messages/ReactionPicker.test.tsx
new file mode 100644
index 000000000..172936ca7
--- /dev/null
+++ b/src/__tests__/components/messages/ReactionPicker.test.tsx
@@ -0,0 +1,121 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-require-imports */
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
+
+import { ReactionPicker } from '@/components/messages/ReactionPicker';
+
+// Mock Ionicons
+jest.mock('@expo/vector-icons', () => ({
+ Ionicons: 'Ionicons',
+}));
+
+// Mock Emoji Picker
+jest.mock('rn-emoji-keyboard', () => {
+ const mockReact = require('react');
+ const { View, Text } = require('react-native');
+
+ return function MockEmojiPicker(props: any) {
+ if (!props.open) return null;
+
+ return mockReact.createElement(
+ View,
+ { testID: 'emoji-picker' },
+ mockReact.createElement(
+ Text,
+ {
+ testID: 'emoji-picker-item',
+ onPress: () => props.onEmojiSelected({ emoji: '🔥' }),
+ },
+ '🔥'
+ )
+ );
+ };
+});
+
+// Default mock Zustand store
+jest.mock('@/stores/reactionStore', () => {
+ const reactions = ['😂', '👍', '❤️'];
+ return {
+ useReactionStore: () => ({
+ addRecentReaction: jest.fn(),
+ loadReactions: jest.fn(),
+ getDisplayReactions: () => reactions,
+ }),
+ };
+});
+
+// Mock theme hook
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({
+ theme: 'light',
+ }),
+}));
+
+// Mock color theme
+jest.mock('@/utils/colorTheme', () => ({
+ colors: {
+ light: {
+ dialogBackground: '#fff',
+ border: '#ddd',
+ mutedForeground: '#999',
+ primary: '#00f',
+ accent: '#eee',
+ foreground: '#000',
+ },
+ },
+}));
+
+describe('ReactionPicker', () => {
+ const baseProps = {
+ visible: true,
+ onClose: jest.fn(),
+ onSelectEmoji: jest.fn(),
+ messagePosition: { y: 200 },
+ isMine: false,
+ currentReaction: null,
+ };
+
+ beforeEach(() => jest.clearAllMocks());
+
+ it('renders nothing when visible=false', () => {
+ const { queryByTestId } = render( );
+ expect(queryByTestId('reaction-overlay')).toBeNull();
+ });
+
+ it('renders quick reactions', () => {
+ const { getByText } = render( );
+ expect(getByText('😂')).toBeTruthy();
+ expect(getByText('👍')).toBeTruthy();
+ expect(getByText('❤️')).toBeTruthy();
+ });
+
+ it('calls onSelectEmoji on quick tap', () => {
+ const { getByText } = render( );
+ fireEvent.press(getByText('😂'));
+ expect(baseProps.onSelectEmoji).toHaveBeenCalledWith('😂');
+ expect(baseProps.onClose).toHaveBeenCalled();
+ });
+
+ it('opens full picker via "more" button', async () => {
+ const { getByTestId } = render( );
+ fireEvent.press(getByTestId('more-button'));
+
+ await waitFor(() => expect(getByTestId('emoji-picker-item')).toBeTruthy());
+ });
+
+ it('selects emoji from full picker', async () => {
+ const { getByTestId } = render( );
+
+ fireEvent.press(getByTestId('more-button'));
+ fireEvent.press(await waitFor(() => getByTestId('emoji-picker-item')));
+
+ expect(baseProps.onSelectEmoji).toHaveBeenCalledWith('🔥');
+ });
+
+ it('closes when overlay pressed', () => {
+ const { getByTestId } = render( );
+ fireEvent.press(getByTestId('reaction-overlay'));
+
+ expect(baseProps.onClose).toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/components/messages/TypingIndicator.test.tsx b/src/__tests__/components/messages/TypingIndicator.test.tsx
new file mode 100644
index 000000000..9411d177d
--- /dev/null
+++ b/src/__tests__/components/messages/TypingIndicator.test.tsx
@@ -0,0 +1,64 @@
+import { Animated } from 'react-native';
+
+import { render } from '@testing-library/react-native';
+
+import TypingIndicator from '@/components/messages/TypingIndicator';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'dark' as const }),
+}));
+
+jest.mock('@/utils/colorTheme', () => ({
+ colors: {
+ dark: {
+ search: '#222222',
+ foreground: '#ffffff',
+ },
+ },
+}));
+
+const mockStart = jest.fn();
+const mockStop = jest.fn();
+
+const createMockAnimation = () => ({
+ start: mockStart,
+ stop: mockStop,
+ reset: jest.fn(),
+});
+
+jest.spyOn(Animated, 'timing').mockImplementation(() => createMockAnimation());
+
+jest.spyOn(Animated, 'loop').mockImplementation(() => createMockAnimation());
+
+jest.spyOn(Animated, 'sequence').mockImplementation(() => createMockAnimation());
+
+jest.spyOn(Animated, 'delay').mockImplementation(() => createMockAnimation());
+
+describe('TypingIndicator', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders three animated dots', () => {
+ const { getAllByTestId } = render( );
+
+ const dots = getAllByTestId('typing-dot');
+ expect(dots.length).toBe(3);
+ });
+
+ it('creates animation loops for all three dots', () => {
+ render( );
+ expect(Animated.loop).toHaveBeenCalledTimes(3);
+ });
+
+ it('starts animations on mount', () => {
+ render( );
+ expect(mockStart).toHaveBeenCalled();
+ });
+
+ it('stops animations on unmount', () => {
+ const { unmount } = render( );
+ unmount();
+ expect(mockStop).toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/components/notifications/FollowNotificationItem.test.tsx b/src/__tests__/components/notifications/FollowNotificationItem.test.tsx
index 47ffdb899..b18c59256 100644
--- a/src/__tests__/components/notifications/FollowNotificationItem.test.tsx
+++ b/src/__tests__/components/notifications/FollowNotificationItem.test.tsx
@@ -1,7 +1,7 @@
import { Alert } from 'react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { fireEvent, render } from '@testing-library/react-native';
+import { act, fireEvent, render } from '@testing-library/react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { FollowNotificationItem } from '@/components/notifications/types/FollowNotificationItem';
@@ -123,7 +123,6 @@ describe('FollowNotificationItem', () => {
);
- // Button text is rendered with lowercase from nativewind
const followButton = getByText('Follow back');
expect(followButton).toBeTruthy();
});
@@ -185,12 +184,13 @@ describe('FollowNotificationItem', () => {
const followButton = getByText('Follow back');
fireEvent.press(followButton);
- // Simulate error by calling the error callback
const mutateCall = mockFollowMutation.mutate.mock.calls[0];
const errorCallback = mutateCall[1]?.onError;
if (errorCallback) {
- errorCallback(new Error('Network error'));
+ act(() => {
+ errorCallback(new Error('Network error'));
+ });
}
expect(mockAlert).toHaveBeenCalledWith('Unable to update follow', 'Network error');
@@ -292,9 +292,46 @@ describe('FollowNotificationItem', () => {
const nameText = getByText('Follower One');
- // The name text is within a pressable container
fireEvent.press(nameText.parent?.parent || nameText);
expect(mockOnPress).toHaveBeenCalled();
});
+
+ it('navigates to user profile when avatar is pressed', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const avatarGroup = getByTestId('actor-avatar-group');
+ fireEvent.press(avatarGroup);
+
+ expect(mockOnPress).toHaveBeenCalled();
+ });
+
+ it('should use default error message if error has no message', () => {
+ const mockAlert = jest.spyOn(Alert, 'alert');
+
+ const { getByText } = render(
+
+
+
+ );
+
+ const followButton = getByText('Follow back');
+ fireEvent.press(followButton);
+
+ const mutateCall = mockFollowMutation.mutate.mock.calls[0];
+ const errorCallback = mutateCall[1]?.onError;
+
+ if (errorCallback) {
+ act(() => {
+ errorCallback({});
+ });
+ }
+
+ expect(mockAlert).toHaveBeenCalledWith('Unable to update follow', 'Please try again.');
+ mockAlert.mockRestore();
+ });
});
diff --git a/src/__tests__/components/notifications/NotificationItem.test.tsx b/src/__tests__/components/notifications/NotificationItem.test.tsx
index 45aadcd00..d7ea32391 100644
--- a/src/__tests__/components/notifications/NotificationItem.test.tsx
+++ b/src/__tests__/components/notifications/NotificationItem.test.tsx
@@ -92,6 +92,13 @@ const createMockTweet = () => ({
username: 'testuser',
displayName: 'Test User',
avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
content: 'Test content',
createdAt: '2024-01-01T00:00:00Z',
diff --git a/src/__tests__/components/notifications/NotificationsList.test.tsx b/src/__tests__/components/notifications/NotificationsList.test.tsx
index f1fcdc69f..9e58e0d6e 100644
--- a/src/__tests__/components/notifications/NotificationsList.test.tsx
+++ b/src/__tests__/components/notifications/NotificationsList.test.tsx
@@ -108,7 +108,13 @@ describe('NotificationsList', () => {
username: 'follower1',
displayName: 'Follower One',
avatarUrl: '',
- isFollowing: false,
+ relationship: {
+ following: false,
+ follower: null,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
],
totalCount: 1,
@@ -125,7 +131,13 @@ describe('NotificationsList', () => {
username: 'follower2',
displayName: 'Follower Two',
avatarUrl: '',
- isFollowing: false,
+ relationship: {
+ following: false,
+ follower: null,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
],
totalCount: 1,
diff --git a/src/__tests__/components/notifications/TweetActionBar.test.tsx b/src/__tests__/components/notifications/TweetActionBar.test.tsx
index 89e5fb343..f6656dd8b 100644
--- a/src/__tests__/components/notifications/TweetActionBar.test.tsx
+++ b/src/__tests__/components/notifications/TweetActionBar.test.tsx
@@ -40,6 +40,7 @@ describe('TweetActionBar', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockQueryClient.clear(); // Clear the query cache between tests
(tweetsService.likeTweet as jest.Mock).mockResolvedValue({});
(tweetsService.unlikeTweet as jest.Mock).mockResolvedValue({});
(tweetsService.retweetTweet as jest.Mock).mockResolvedValue({});
@@ -91,7 +92,7 @@ describe('TweetActionBar', () => {
expect(tweetsService.likeTweet).toHaveBeenCalledWith('tweet-1');
});
- expect(getByText('11')).toBeTruthy(); // like count increased
+ await waitFor(() => expect(getByText('11')).toBeTruthy()); // like count increased
});
it('should handle unlike action', async () => {
@@ -119,7 +120,7 @@ describe('TweetActionBar', () => {
expect(tweetsService.unlikeTweet).toHaveBeenCalledWith('tweet-1');
});
- expect(getByText('9')).toBeTruthy(); // like count decreased
+ await waitFor(() => expect(getByText('9')).toBeTruthy()); // like count decreased
});
it('should handle retweet action', async () => {
@@ -147,7 +148,7 @@ describe('TweetActionBar', () => {
expect(tweetsService.retweetTweet).toHaveBeenCalledWith('tweet-1');
});
- expect(getByText('6')).toBeTruthy(); // retweet count increased
+ await waitFor(() => expect(getByText('6')).toBeTruthy()); // retweet count increased
});
it('should handle unretweet action', async () => {
@@ -175,7 +176,7 @@ describe('TweetActionBar', () => {
expect(tweetsService.unretweetTweet).toHaveBeenCalledWith('tweet-1');
});
- expect(getByText('4')).toBeTruthy(); // retweet count decreased
+ await waitFor(() => expect(getByText('4')).toBeTruthy()); // retweet count decreased
});
it('should call onReplyPress when reply button is pressed', () => {
diff --git a/src/__tests__/components/notifications/TweetLikeNotificationItem.test.tsx b/src/__tests__/components/notifications/TweetLikeNotificationItem.test.tsx
index 5f0f476b1..fa4d34bee 100644
--- a/src/__tests__/components/notifications/TweetLikeNotificationItem.test.tsx
+++ b/src/__tests__/components/notifications/TweetLikeNotificationItem.test.tsx
@@ -1,9 +1,10 @@
import { useNavigation } from '@react-navigation/native';
-import { render } from '@testing-library/react-native';
+import { fireEvent, render } from '@testing-library/react-native';
import { TweetLikeNotificationItem } from '@/components/notifications/types/TweetLikeNotificationItem';
import { useRootNavigation } from '@/hooks/navigation/useRootNavigation';
import { NotificationType, TweetNotification } from '@/types/notifications';
+import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames';
jest.mock('@/hooks/useTheme', () => ({
useTheme: () => ({ theme: 'light' }),
@@ -21,14 +22,15 @@ jest.mock('@/hooks/navigation/useRootNavigation', () => ({
})),
}));
-jest.mock('@/components/ui/ActorAvatarGroup', () => () => null);
-
+jest.mock('@/components/ui/ActorAvatarGroup', () => 'ActorAvatarGroup');
jest.mock('@/components/notifications/types/TweetActionBar', () => ({
- TweetActionBar: () => null,
+ TweetActionBar: 'TweetActionBar',
}));
describe('TweetLikeNotificationItem', () => {
- const mockOnPress = jest.fn();
+ const mockOnPress = jest.fn((callback) => callback());
+ const mockNavigate = jest.fn();
+ const mockRootNavigate = jest.fn();
const createNotification = (overrides?: Partial): TweetNotification => ({
id: '1',
@@ -53,6 +55,13 @@ describe('TweetLikeNotificationItem', () => {
username: 'author',
displayName: 'Author',
avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
content: 'Test tweet content',
createdAt: '2024-01-01T00:00:00Z',
@@ -75,19 +84,28 @@ describe('TweetLikeNotificationItem', () => {
beforeEach(() => {
jest.clearAllMocks();
+ (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate });
+ (useRootNavigation as jest.Mock).mockReturnValue({ navigate: mockRootNavigate });
});
- it('renders single user like notification', () => {
+ it('renders correctly and handles main press', () => {
const notification = createNotification();
const { getByText } = render(
);
- expect(getByText(/User One/)).toBeTruthy();
+ expect(getByText('User One')).toBeTruthy();
expect(getByText(/liked your tweet/)).toBeTruthy();
+ expect(getByText('Test tweet content')).toBeTruthy();
+
+ const contentText = getByText('Test tweet content');
+ fireEvent.press(contentText);
+
+ expect(mockOnPress).toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'tweet1' });
});
- it('renders multiple users like notification', () => {
+ it('renders multiple users like notification and handles interaction', () => {
const notification = createNotification({
actorSummary: {
previewActors: [
@@ -106,49 +124,37 @@ describe('TweetLikeNotificationItem', () => {
);
- expect(getByText(/User One/)).toBeTruthy();
- expect(getByText(/4 others/)).toBeTruthy();
+ expect(getByText('User One')).toBeTruthy();
+ expect(getByText(/and 4 others/)).toBeTruthy(); // Updated expectation logic if "and" is present
+
+ // Pressing the notification should still navigate to tweet
+ fireEvent.press(getByText('User One'));
+ expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'tweet1' });
});
- it('renders tweet content when available', () => {
+ it('navigates to user profile when actor avatar is pressed', () => {
const notification = createNotification();
- const { getByText } = render(
+ const { getByTestId } = render(
);
- expect(getByText(/Test tweet content/)).toBeTruthy();
+ // Press the avatar pressable wrapper which triggers handleActorPress
+ const avatarPressable = getByTestId('actor-avatar-pressable');
+ fireEvent.press(avatarPressable);
+
+ expect(mockOnPress).toHaveBeenCalled();
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: 'user1' },
+ });
});
- it('renders notification with media', () => {
+ it('handles hashtag press in tweet content', () => {
const notification = createNotification({
tweetSummary: {
primaryTweet: {
- id: 'tweet1',
- author: {
- username: 'author',
- displayName: 'Author',
- avatarUrl: null,
- },
- content: 'Test tweet',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 0,
- likeCount: 1,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: [
- {
- type: 'IMAGE',
- url: 'https://example.com/image.jpg',
- altText: 'Test image',
- width: 100,
- height: 100,
- },
- ],
- replyToTweetId: null,
- quoteToTweetId: null,
- quotedTweet: null,
+ ...createNotification().tweetSummary.primaryTweet,
+ content: 'Hello #testhashtag',
},
totalCount: 1,
subjectIds: ['tweet1'],
@@ -159,79 +165,38 @@ describe('TweetLikeNotificationItem', () => {
);
- expect(getByText(/Test tweet/)).toBeTruthy();
- });
+ const hashtag = getByText('#testhashtag');
+ fireEvent.press(hashtag);
- it('handles notification press correctly', () => {
- const mockNavigate = jest.fn();
- (useNavigation as jest.Mock).mockReturnValue({
- navigate: mockNavigate,
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.SEARCH, {
+ initialQuery: '#testhashtag',
+ initialTab: 'top',
});
+ });
+ it('renders without content when tweet content is empty', () => {
const notification = createNotification();
- render( );
+ notification.tweetSummary.primaryTweet.content = '';
- expect(mockOnPress).toBeDefined();
- });
-
- it('handles mention press in tweet content', () => {
- const mockRootNavigate = jest.fn();
- (useRootNavigation as jest.Mock).mockReturnValue({
- navigate: mockRootNavigate,
- });
+ const { getByText } = render(
+
+ );
- const notification = createNotification({
- tweetSummary: {
- primaryTweet: {
- id: 'tweet1',
- author: {
- username: 'author',
- displayName: 'Author',
- avatarUrl: null,
- },
- content: 'Hello @testuser this is a test',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 0,
- likeCount: 1,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- replyToTweetId: null,
- quoteToTweetId: null,
- quotedTweet: null,
- },
- totalCount: 1,
- subjectIds: ['tweet1'],
- },
- });
+ expect(getByText(/liked your tweet/)).toBeTruthy();
+ });
+ it('renders seen styling', () => {
+ const notification = createNotification();
+ notification.isSeen = true;
render( );
});
- it('renders without content when tweet content is empty', () => {
+ it('handles mention press in tweet content', () => {
const notification = createNotification({
tweetSummary: {
primaryTweet: {
- id: 'tweet1',
- author: {
- username: 'author',
- displayName: 'Author',
- avatarUrl: null,
- },
- content: '',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 0,
- likeCount: 1,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- replyToTweetId: null,
- quoteToTweetId: null,
- quotedTweet: null,
+ ...createNotification().tweetSummary.primaryTweet,
+ content: 'Hello @testuser',
},
totalCount: 1,
subjectIds: ['tweet1'],
@@ -242,60 +207,45 @@ describe('TweetLikeNotificationItem', () => {
);
- expect(getByText(/User One/)).toBeTruthy();
- });
+ const mention = getByText('@testuser');
+ fireEvent.press(mention);
- it('handles tweet press correctly', () => {
- const mockNavigate = jest.fn();
- (useRootNavigation as jest.Mock).mockReturnValue({
- navigate: mockNavigate,
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: 'testuser' },
});
-
- const notification = createNotification();
- render( );
-
- // Component has actor avatar that can be pressed
- expect(mockOnPress).toBeDefined();
});
- it('handles hashtag press in tweet content', () => {
- const mockRootNavigate = jest.fn();
- (useRootNavigation as jest.Mock).mockReturnValue({
- navigate: mockRootNavigate,
- });
-
+ it('renders tweet with media', () => {
const notification = createNotification({
tweetSummary: {
primaryTweet: {
- id: 'tweet1',
- author: {
- username: 'author',
- displayName: 'Author',
- avatarUrl: null,
- },
- content: 'Hello #testhashtag this is a test',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 0,
- likeCount: 1,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- replyToTweetId: null,
- quoteToTweetId: null,
- quotedTweet: null,
+ ...createNotification().tweetSummary.primaryTweet,
+ content: 'Tweet with media',
+ media: [
+ {
+ type: 'IMAGE',
+ url: 'https://example.com/image.jpg',
+ altText: 'Test image',
+ width: 100,
+ height: 100,
+ },
+ ],
},
totalCount: 1,
subjectIds: ['tweet1'],
},
});
- render( );
- expect(mockRootNavigate).toBeDefined();
+ const { getByText, getByTestId } = render(
+
+ );
+
+ expect(getByText('Tweet with media')).toBeTruthy();
+ expect(getByTestId('tweet-media-image')).toBeTruthy();
});
- it('handles case with no primary actor gracefully', () => {
+ it('handles empty actors array gracefully', () => {
const notification = createNotification({
actorSummary: {
previewActors: [],
@@ -303,10 +253,17 @@ describe('TweetLikeNotificationItem', () => {
},
});
- const { getByText } = render(
+ const { getByText, getByTestId } = render(
);
expect(getByText(/liked your tweet/)).toBeTruthy();
+
+ // Pressing avatar should not navigate when no primary actor
+ const avatarPressable = getByTestId('actor-avatar-pressable');
+ fireEvent.press(avatarPressable);
+
+ // Should not have called rootNavigate for profile since no actor
+ expect(mockRootNavigate).not.toHaveBeenCalledWith(ROOT.PROFILE, expect.anything());
});
});
diff --git a/src/__tests__/components/notifications/TweetMentionNotificationItem.test.tsx b/src/__tests__/components/notifications/TweetMentionNotificationItem.test.tsx
index be1a4cb48..15a4c8654 100644
--- a/src/__tests__/components/notifications/TweetMentionNotificationItem.test.tsx
+++ b/src/__tests__/components/notifications/TweetMentionNotificationItem.test.tsx
@@ -1,9 +1,10 @@
import { useNavigation } from '@react-navigation/native';
-import { render } from '@testing-library/react-native';
+import { fireEvent, render } from '@testing-library/react-native';
import { TweetMentionNotificationItem } from '@/components/notifications/types/TweetMentionNotificationItem';
import { useRootNavigation } from '@/hooks/navigation/useRootNavigation';
import { NotificationType, TweetNotification } from '@/types/notifications';
+import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames';
jest.mock('@/hooks/useTheme', () => ({
useTheme: () => ({ theme: 'light' }),
@@ -21,16 +22,29 @@ jest.mock('@/hooks/navigation/useRootNavigation', () => ({
})),
}));
-jest.mock('@/components/ui/ActorAvatarGroup', () => () => null);
-jest.mock('@/components/ui/TweetMediaGrid', () => () => null);
+jest.mock('@/components/ui/ActorAvatarGroup', () => {
+ const { Text } = jest.requireActual('react-native');
+ const MockActorAvatarGroup = () => ActorAvatarGroup ;
+ MockActorAvatarGroup.displayName = 'MockActorAvatarGroup';
+ return MockActorAvatarGroup;
+});
+jest.mock('@/components/ui/TweetMediaGrid', () => 'TweetMediaGrid');
jest.mock('@/components/notifications/types/TweetActionBar', () => ({
- TweetActionBar: () => null,
+ TweetActionBar: 'TweetActionBar',
}));
describe('TweetMentionNotificationItem', () => {
- const mockOnPress = jest.fn();
+ const mockOnPress = jest.fn((callback) => callback());
+ const mockNavigate = jest.fn();
+ const mockRootNavigate = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate });
+ (useRootNavigation as jest.Mock).mockReturnValue({ navigate: mockRootNavigate });
+ });
- const createNotification = (): TweetNotification => ({
+ const createNotification = (overrides?: Partial): TweetNotification => ({
id: '1',
type: NotificationType.MENTION,
isSeen: false,
@@ -53,12 +67,19 @@ describe('TweetMentionNotificationItem', () => {
username: 'author',
displayName: 'Author',
avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
- content: 'Test tweet content',
+ content: 'Test tweet content mention @target and #hashtag',
createdAt: '2024-01-01T00:00:00Z',
replyCount: 0,
retweetCount: 0,
- likeCount: 1,
+ likeCount: 0,
isLiked: false,
isRetweeted: false,
entities: { mentions: null, hashtags: null },
@@ -70,157 +91,116 @@ describe('TweetMentionNotificationItem', () => {
totalCount: 1,
subjectIds: ['tweet1'],
},
+ ...overrides,
});
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('renders mention notification', () => {
+ it('renders correctly and handles main press', () => {
const notification = createNotification();
const { getByText } = render(
);
- expect(getByText(/User One/)).toBeTruthy();
+ expect(getByText('User One')).toBeTruthy();
expect(getByText(/mentioned you/)).toBeTruthy();
+ expect(getByText('Test tweet content mention @target and #hashtag')).toBeTruthy();
+
+ // Pressing the notification body (or name text) goes to Tweet
+ const contentText = getByText('Test tweet content mention @target and #hashtag');
+ fireEvent.press(contentText);
+
+ expect(mockOnPress).toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'tweet1' });
});
- it('renders tweet content', () => {
+ it('navigates to user profile when actor avatar is pressed', () => {
const notification = createNotification();
const { getByText } = render(
);
- expect(getByText(/Test tweet content/)).toBeTruthy();
- });
+ const avatar = getByText('ActorAvatarGroup');
+ fireEvent.press(avatar);
- it('renders notification with media', () => {
- const notificationWithMedia = {
- ...createNotification(),
- tweetSummary: {
- primaryTweet: {
- ...createNotification().tweetSummary.primaryTweet,
- media: [
- {
- type: 'IMAGE' as const,
- url: 'https://example.com/image.jpg',
- altText: 'Test image',
- width: 100,
- height: 100,
- },
- ],
- },
- totalCount: 1,
- subjectIds: ['tweet1'],
- },
- };
+ expect(mockOnPress).toHaveBeenCalled();
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: 'user1' },
+ });
+ });
+ it('handles mention press inside tweet content', () => {
+ const notification = createNotification();
const { getByText } = render(
-
+
);
- expect(getByText(/User One/)).toBeTruthy();
- });
+ const mention = getByText('@target');
+ fireEvent.press(mention);
- it('handles notification press correctly', () => {
- const mockNavigate = jest.fn();
- (useNavigation as jest.Mock).mockReturnValue({
- navigate: mockNavigate,
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: 'target' },
});
-
- const notification = createNotification();
- render( );
-
- expect(mockOnPress).toBeDefined();
});
- it('handles mention press in tweet content', () => {
- const mockRootNavigate = jest.fn();
- (useRootNavigation as jest.Mock).mockReturnValue({
- navigate: mockRootNavigate,
- });
-
+ it('handles hashtag press inside tweet content', () => {
const notification = createNotification();
- render( );
- });
-
- it('handles hashtag press in tweet content', () => {
- const mockRootNavigate = jest.fn();
- (useRootNavigation as jest.Mock).mockReturnValue({
- navigate: mockRootNavigate,
- });
+ const { getByText } = render(
+
+ );
- const notification = createNotification();
- render( );
- expect(mockRootNavigate).toBeDefined();
- });
+ const hashtag = getByText('#hashtag');
+ fireEvent.press(hashtag);
- it('handles actor press correctly', () => {
- const mockRootNavigate = jest.fn();
- (useRootNavigation as jest.Mock).mockReturnValue({
- navigate: mockRootNavigate,
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.SEARCH, {
+ initialQuery: '#hashtag',
+ initialTab: 'top',
});
-
- const notification = createNotification();
- render( );
- expect(mockOnPress).toBeDefined();
});
- it('handles case with no primary actor gracefully', () => {
- const notificationNoActor = {
- ...createNotification(),
+ it('handles missing primary actor gracefully', () => {
+ const notification = createNotification({
actorSummary: {
previewActors: [],
totalCount: 0,
},
- };
+ });
- const { getByText } = render(
-
+ const { getByText, queryByText } = render(
+
);
expect(getByText(/mentioned you/)).toBeTruthy();
+ // User One should not be present
+ expect(queryByText('User One')).toBeNull();
});
- it('renders with hashtags in content', () => {
- const notificationWithHashtag = {
- ...createNotification(),
- tweetSummary: {
- ...createNotification().tweetSummary,
- primaryTweet: {
- ...createNotification().tweetSummary.primaryTweet,
- content: 'Test tweet with #hashtag content',
- },
- },
- };
-
- const { getByText } = render(
-
- );
-
- expect(getByText(/Test tweet with #hashtag content/)).toBeTruthy();
- });
-
- it('renders without media when media is null', () => {
- const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
-
- const notificationWithHashtag = {
- ...createNotification(),
+ it('renders with media', () => {
+ const notification = createNotification({
tweetSummary: {
primaryTweet: {
...createNotification().tweetSummary.primaryTweet,
- content: 'Test tweet with #hashtag',
+ media: [
+ {
+ type: 'IMAGE',
+ url: 'https://example.com/image.jpg',
+ altText: 'Test image',
+ width: 100,
+ height: 100,
+ },
+ ],
},
totalCount: 1,
subjectIds: ['tweet1'],
},
- };
+ });
- render(
-
- );
+ render( );
+ });
- consoleSpy.mockRestore();
+ it('renders seen styling', () => {
+ const notification = createNotification();
+ notification.isSeen = true;
+ render( );
});
});
diff --git a/src/__tests__/components/notifications/TweetQuoteNotificationItem.test.tsx b/src/__tests__/components/notifications/TweetQuoteNotificationItem.test.tsx
index d22eb7666..8b13ae8fc 100644
--- a/src/__tests__/components/notifications/TweetQuoteNotificationItem.test.tsx
+++ b/src/__tests__/components/notifications/TweetQuoteNotificationItem.test.tsx
@@ -1,33 +1,49 @@
-import { render } from '@testing-library/react-native';
+import { useNavigation } from '@react-navigation/native';
+import { fireEvent, render } from '@testing-library/react-native';
import { TweetQuoteNotificationItem } from '@/components/notifications/types/TweetQuoteNotificationItem';
+import { useRootNavigation } from '@/hooks/navigation/useRootNavigation';
import { NotificationType, TweetNotification } from '@/types/notifications';
+import { ROOT, TWEET } from '@/utils/navigation/routeNames';
jest.mock('@/hooks/useTheme', () => ({
useTheme: () => ({ theme: 'light' }),
}));
-const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({
- navigate: mockNavigate,
- }),
+ useNavigation: jest.fn(() => ({
+ navigate: jest.fn(),
+ })),
}));
-const mockRootNavigate = jest.fn();
jest.mock('@/hooks/navigation/useRootNavigation', () => ({
- useRootNavigation: () => ({
- navigate: mockRootNavigate,
- }),
+ useRootNavigation: jest.fn(() => ({
+ navigate: jest.fn(),
+ })),
}));
-jest.mock('@/components/ui/ActorAvatarGroup', () => () => null);
-jest.mock('@/components/notifications/types/TweetActionBar', () => ({
- TweetActionBar: () => null,
-}));
+jest.mock('@/components/ui/ActorAvatarGroup', () => 'ActorAvatarGroup');
+jest.mock('@/components/notifications/types/TweetActionBar', () => {
+ const { Pressable, Text } = require('react-native');
+ return {
+ TweetActionBar: ({ onReplyPress }: { onReplyPress: () => void }) => (
+
+ Reply
+
+ ),
+ };
+});
describe('TweetQuoteNotificationItem', () => {
- const mockOnPress = jest.fn();
+ const mockOnPress = jest.fn((callback) => callback());
+ const mockNavigate = jest.fn();
+ const mockRootNavigate = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate });
+ (useRootNavigation as jest.Mock).mockReturnValue({ navigate: mockRootNavigate });
+ });
const createNotification = (overrides?: Partial): TweetNotification => ({
id: '1',
@@ -52,6 +68,13 @@ describe('TweetQuoteNotificationItem', () => {
username: 'quoter',
displayName: 'Quoter',
avatarUrl: null,
+ relationship: {
+ follower: false,
+ following: false,
+ muted: false,
+ blockedBy: false,
+ blocking: false,
+ },
},
content: 'This is a quote tweet with commentary',
createdAt: '2024-01-01T00:00:00Z',
@@ -70,6 +93,13 @@ describe('TweetQuoteNotificationItem', () => {
username: 'author',
displayName: 'Original Author',
avatarUrl: null,
+ relationship: {
+ follower: false,
+ following: false,
+ muted: false,
+ blockedBy: false,
+ blocking: false,
+ },
},
content: 'Original tweet content',
createdAt: '2024-01-01T00:00:00Z',
@@ -88,18 +118,21 @@ describe('TweetQuoteNotificationItem', () => {
...overrides,
});
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('renders single user quote notification', () => {
+ it('renders correctly and handles main press', () => {
const notification = createNotification();
const { getByText } = render(
);
- expect(getByText(/User One/)).toBeTruthy();
+ expect(getByText('User One')).toBeTruthy();
expect(getByText(/quoted your tweet/)).toBeTruthy();
+ expect(getByText('This is a quote tweet with commentary')).toBeTruthy();
+ expect(getByText('Original tweet content')).toBeTruthy();
+
+ fireEvent.press(getByText('This is a quote tweet with commentary'));
+
+ expect(mockOnPress).toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'quoted-tweet1' });
});
it('renders multiple users quote notification', () => {
@@ -121,70 +154,16 @@ describe('TweetQuoteNotificationItem', () => {
);
- expect(getByText(/User One/)).toBeTruthy();
- expect(getByText(/2 others/)).toBeTruthy();
- });
-
- it('renders quote tweet content', () => {
- const notification = createNotification();
- const { getByText } = render(
-
- );
-
- expect(getByText(/This is a quote tweet with commentary/)).toBeTruthy();
- });
-
- it('renders original tweet content in preview card', () => {
- const notification = createNotification();
- const { getByText } = render(
-
- );
-
- expect(getByText(/Original tweet content/)).toBeTruthy();
- expect(getByText(/Original Author/)).toBeTruthy();
- });
-
- it('renders without original tweet content', () => {
- const notification = createNotification({
- tweetSummary: {
- primaryTweet: {
- ...createNotification().tweetSummary.primaryTweet,
- quotedTweet: {
- id: 'original-tweet1',
- author: {
- username: 'author',
- displayName: 'Original Author',
- avatarUrl: null,
- },
- content: '',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 0,
- likeCount: 0,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- },
- },
- totalCount: 1,
- subjectIds: ['original-tweet1'],
- },
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText(/Original Author/)).toBeTruthy();
+ expect(getByText('User One')).toBeTruthy();
+ expect(getByText(/and 2 others/)).toBeTruthy();
});
- it('handles case with no quoted tweet', () => {
+ it('handles hashtag press in quote tweet content', () => {
const notification = createNotification({
tweetSummary: {
primaryTweet: {
...createNotification().tweetSummary.primaryTweet,
- quotedTweet: null,
+ content: 'Commentary with #hashtag',
},
totalCount: 1,
subjectIds: ['original-tweet1'],
@@ -195,197 +174,39 @@ describe('TweetQuoteNotificationItem', () => {
);
- expect(getByText(/User One/)).toBeTruthy();
- });
+ fireEvent.press(getByText('#hashtag'));
- it('renders quote notification without quote tweet content', () => {
- const notification = createNotification({
- tweetSummary: {
- primaryTweet: {
- ...createNotification().tweetSummary.primaryTweet,
- content: '',
- },
- totalCount: 1,
- subjectIds: ['original-tweet1'],
- },
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.SEARCH, {
+ initialQuery: '#hashtag',
+ initialTab: 'top',
});
-
- const { getByText } = render(
-
- );
-
- expect(getByText(/Original Author/)).toBeTruthy();
});
- it('handles actor press correctly', () => {
+ it('navigates to user profile when actor is pressed', () => {
const notification = createNotification();
- render( );
- expect(mockRootNavigate).toBeDefined();
- });
-
- it('handles case with no primary actor gracefully', () => {
- const notification = createNotification({
- actorSummary: {
- previewActors: [],
- totalCount: 0,
- },
- });
-
const { getByText } = render(
);
- expect(getByText(/quoted your tweet/)).toBeTruthy();
+ fireEvent.press(getByText('User One'));
+ expect(mockOnPress).toHaveBeenCalled();
});
- it('renders quote notification with media in quote tweet', () => {
- const notification = createNotification({
- tweetSummary: {
- primaryTweet: {
- id: 'quoted-tweet1',
- author: {
- username: 'quoter',
- displayName: 'Quoter',
- avatarUrl: null,
- },
- content: 'Quote with image',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 0,
- likeCount: 0,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: [
- {
- type: 'IMAGE',
- url: 'https://example.com/image.jpg',
- altText: 'Quote image',
- width: 100,
- height: 100,
- },
- ],
- replyToTweetId: null,
- quoteToTweetId: 'original-tweet1',
- quotedTweet: {
- id: 'original-tweet1',
- author: {
- username: 'author',
- displayName: 'Original Author',
- avatarUrl: null,
- },
- content: 'Original tweet',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 0,
- likeCount: 0,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- },
- },
- totalCount: 1,
- subjectIds: ['original-tweet1'],
- },
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText(/Quote with image/)).toBeTruthy();
- });
-
- it('renders multiple quote tweets', () => {
- const notification = createNotification({
- tweetSummary: {
- primaryTweet: {
- id: 'quoted-tweet1',
- author: {
- username: 'quoter',
- displayName: 'Quoter',
- avatarUrl: null,
- },
- content: 'Quote tweet',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 0,
- likeCount: 0,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- replyToTweetId: null,
- quoteToTweetId: null,
- quotedTweet: null,
- },
- totalCount: 2,
- subjectIds: ['original-tweet1', 'original-tweet2'],
- },
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText(/Quote tweet/)).toBeTruthy();
- });
-
- it('handles missing quoted tweet gracefully', () => {
- const notification = createNotification({
- tweetSummary: {
- primaryTweet: {
- id: 'quoted-tweet1',
- author: {
- username: 'quoter',
- displayName: 'Quoter',
- avatarUrl: null,
- },
- content: 'Quote tweet',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 0,
- likeCount: 0,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- replyToTweetId: null,
- quoteToTweetId: null,
- quotedTweet: null,
- },
- totalCount: 1,
- subjectIds: ['original-tweet1'],
- },
- });
-
- const { getByText } = render(
+ it('handles reply press', () => {
+ const notification = createNotification();
+ const { getByTestId } = render(
);
- expect(getByText(/Quote tweet/)).toBeTruthy();
- });
+ const replyButton = getByTestId('reply-button');
+ fireEvent.press(replyButton);
- it('handles mention press in quote content', () => {
- const notification = createNotification({
- tweetSummary: {
- primaryTweet: {
- ...createNotification().tweetSummary.primaryTweet,
- content: 'Quote with @mention in it',
- },
- totalCount: 1,
- subjectIds: ['original-tweet1'],
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.TWEET, {
+ screen: TWEET.DETAIL,
+ params: {
+ tweetId: 'quoted-tweet1',
+ initialOpenComposer: true,
},
});
-
- render( );
- });
-
- it('handles notification press correctly', () => {
- const notification = createNotification();
- render( );
-
- expect(mockOnPress).toBeDefined();
});
});
diff --git a/src/__tests__/components/notifications/TweetReplyNotificationItem.test.tsx b/src/__tests__/components/notifications/TweetReplyNotificationItem.test.tsx
index 6b60d470e..784044429 100644
--- a/src/__tests__/components/notifications/TweetReplyNotificationItem.test.tsx
+++ b/src/__tests__/components/notifications/TweetReplyNotificationItem.test.tsx
@@ -1,14 +1,15 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-/* eslint-disable @typescript-eslint/no-require-imports */
-import React from 'react';
-
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { render } from '@testing-library/react-native';
-import { SafeAreaProvider } from 'react-native-safe-area-context';
+import { useNavigation } from '@react-navigation/native';
+import { fireEvent, render } from '@testing-library/react-native';
import { TweetReplyNotificationItem } from '@/components/notifications/types/TweetReplyNotificationItem';
-import { ThemeProvider } from '@/hooks/useTheme';
+import { useRootNavigation } from '@/hooks/navigation/useRootNavigation';
import { NotificationType, TweetNotification } from '@/types/notifications';
+import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'light' }),
+ ThemeProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(() => ({
@@ -26,54 +27,25 @@ jest.mock('lucide-react-native', () => ({
CornerUpLeft: () => null,
}));
-jest.mock('react-native-highlighter', () => {
- const mockReact = require('react');
- const mockRN = require('react-native');
- const HighlightedText = (props: any) => {
- return mockReact.createElement(mockRN.Text, {
- ...props,
- children: props.children,
- });
- };
- HighlightedText.displayName = 'HighlightedText';
- return HighlightedText;
-});
-
-jest.mock('@/components/ui/ActorAvatarGroup', () => {
- return () => null;
-});
-
-jest.mock('@/components/ui/TweetMediaGrid', () => {
- return () => null;
-});
-
+jest.mock('@/components/ui/ActorAvatarGroup', () => 'ActorAvatarGroup');
+jest.mock('@/components/ui/TweetMediaGrid', () => 'TweetMediaGrid');
jest.mock('@/components/notifications/types/TweetActionBar', () => ({
- TweetActionBar: () => null,
+ TweetActionBar: 'TweetActionBar',
}));
-const mockQueryClient = new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- mutations: { retry: false },
- },
-});
+describe('TweetReplyNotificationItem', () => {
+ const mockOnPress = jest.fn((callback) => callback());
+ const mockNavigate = jest.fn();
+ const mockRootNavigate = jest.fn();
-const Wrapper = ({ children }: { children: React.ReactNode }) => (
-
-
- {children}
-
-
-);
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate });
+ (useRootNavigation as jest.Mock).mockReturnValue({ navigate: mockRootNavigate });
+ });
-describe('TweetReplyNotificationItem', () => {
- const mockNotification: TweetNotification = {
- id: 'notif-1',
+ const createNotification = (overrides?: Partial): TweetNotification => ({
+ id: '1',
type: NotificationType.REPLY,
isSeen: false,
latestEventAt: '2024-01-01T00:00:00Z',
@@ -82,7 +54,7 @@ describe('TweetReplyNotificationItem', () => {
{
username: 'replier1',
displayName: 'Replier One',
- avatarUrl: '',
+ avatarUrl: 'https://example.com/avatar.jpg',
isFollowing: false,
},
],
@@ -90,11 +62,18 @@ describe('TweetReplyNotificationItem', () => {
},
tweetSummary: {
primaryTweet: {
- id: 'tweet-1',
+ id: 'reply-tweet1',
author: {
username: 'author1',
displayName: 'Author One',
- avatarUrl: '',
+ avatarUrl: null,
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
},
content: 'This is a reply tweet',
createdAt: '2024-01-01T00:00:00Z',
@@ -110,30 +89,29 @@ describe('TweetReplyNotificationItem', () => {
quotedTweet: null,
},
totalCount: 1,
- subjectIds: ['tweet-1'],
+ subjectIds: ['reply-tweet1'],
},
- };
-
- const mockOnPress = jest.fn();
-
- beforeEach(() => {
- jest.clearAllMocks();
+ ...overrides,
});
- it('should render single reply notification', () => {
+ it('renders correctly and handles main press', () => {
+ const notification = createNotification();
const { getByText } = render(
-
-
-
+
);
- expect(getByText(/Replier One replied to your tweet/)).toBeTruthy();
+ expect(getByText('Replier One')).toBeTruthy();
+ expect(getByText(/replied to your tweet/)).toBeTruthy();
expect(getByText('This is a reply tweet')).toBeTruthy();
+
+ fireEvent.press(getByText('This is a reply tweet'));
+
+ expect(mockOnPress).toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'reply-tweet1' });
});
- it('should render multiple replies notification', () => {
- const multipleRepliesNotification: TweetNotification = {
- ...mockNotification,
+ it('renders multiple replies notification', () => {
+ const notification = createNotification({
actorSummary: {
previewActors: [
{
@@ -151,152 +129,145 @@ describe('TweetReplyNotificationItem', () => {
],
totalCount: 3,
},
- };
-
- const { getByText } = render(
-
-
-
- );
-
- expect(getByText(/Replier One/)).toBeTruthy();
- expect(getByText(/2 others/)).toBeTruthy();
- expect(getByText(/replied to your tweet/)).toBeTruthy();
- });
+ });
- it('should display tweet content', () => {
const { getByText } = render(
-
-
-
+
);
- expect(getByText('This is a reply tweet')).toBeTruthy();
+ expect(getByText('Replier One')).toBeTruthy();
+ expect(getByText(/and 2 others/)).toBeTruthy();
});
- it('should render notification with media', () => {
- const notificationWithMedia: TweetNotification = {
- ...mockNotification,
+ it('handles hashtag press in tweet content', () => {
+ const notification = createNotification({
tweetSummary: {
primaryTweet: {
- ...mockNotification.tweetSummary.primaryTweet,
- media: [
- {
- type: 'IMAGE',
- url: 'https://example.com/image.jpg',
- altText: 'Test image',
- width: 100,
- height: 100,
- },
- ],
+ ...createNotification().tweetSummary.primaryTweet,
+ content: 'Reply with #hashtag',
},
totalCount: 1,
- subjectIds: ['tweet-1'],
+ subjectIds: ['reply-tweet1'],
},
- };
+ });
const { getByText } = render(
-
-
-
+
);
- expect(getByText('This is a reply tweet')).toBeTruthy();
+ fireEvent.press(getByText('#hashtag'));
+
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.SEARCH, {
+ initialQuery: '#hashtag',
+ initialTab: 'top',
+ });
});
- it('should handle mention press in tweet content', () => {
- const notificationWithMention: TweetNotification = {
- ...mockNotification,
- tweetSummary: {
- primaryTweet: {
- ...mockNotification.tweetSummary.primaryTweet,
- content: 'Reply with @mention',
- },
- totalCount: 1,
- subjectIds: ['tweet-1'],
- },
- };
+ it('navigates to user profile when actor avatar is pressed', () => {
+ const notification = createNotification();
+ const { getByTestId } = render(
+
+ );
+
+ // Press the avatar pressable wrapper which triggers handleActorPress
+ const avatarPressable = getByTestId('actor-avatar-pressable');
+ fireEvent.press(avatarPressable);
- render(
-
-
-
+ expect(mockOnPress).toHaveBeenCalled();
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: 'replier1' },
+ });
+ });
+
+ it('renders without content when tweet content is empty', () => {
+ const notification = createNotification();
+ notification.tweetSummary.primaryTweet.content = '';
+
+ const { getByText } = render(
+
);
+
+ expect(getByText(/replied to your tweet/)).toBeTruthy();
});
- it('should handle hashtag press in tweet content', () => {
- const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
+ it('renders seen styling', () => {
+ const notification = createNotification();
+ notification.isSeen = true;
+ render( );
+ });
- const notificationWithHashtag: TweetNotification = {
- ...mockNotification,
+ it('handles mention press in tweet content', () => {
+ const notification = createNotification({
tweetSummary: {
primaryTweet: {
- ...mockNotification.tweetSummary.primaryTweet,
- content: 'Reply with #hashtag',
+ ...createNotification().tweetSummary.primaryTweet,
+ content: 'Reply to @mentioneduser',
},
totalCount: 1,
- subjectIds: ['tweet-1'],
+ subjectIds: ['reply-tweet1'],
},
- };
+ });
- render(
-
-
-
+ const { getByText } = render(
+
);
- consoleSpy.mockRestore();
+ const mention = getByText('@mentioneduser');
+ fireEvent.press(mention);
+
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: 'mentioneduser' },
+ });
});
- it('should handle actor press correctly', () => {
- render(
-
-
-
+ it('renders tweet with media', () => {
+ const notification = createNotification({
+ tweetSummary: {
+ primaryTweet: {
+ ...createNotification().tweetSummary.primaryTweet,
+ content: 'Reply with media',
+ media: [
+ {
+ type: 'IMAGE',
+ url: 'https://example.com/image.jpg',
+ altText: 'Test image',
+ width: 100,
+ height: 100,
+ },
+ ],
+ },
+ totalCount: 1,
+ subjectIds: ['reply-tweet1'],
+ },
+ });
+
+ const { getByText } = render(
+
);
- expect(mockOnPress).toBeDefined();
+ expect(getByText('Reply with media')).toBeTruthy();
});
- it('should handle case with no primary actor gracefully', () => {
- const notificationNoActor: TweetNotification = {
- ...mockNotification,
+ it('handles empty actors array gracefully', () => {
+ const notification = createNotification({
actorSummary: {
previewActors: [],
totalCount: 0,
},
- };
+ });
- const { getByText } = render(
-
-
-
+ const { getByText, getByTestId } = render(
+
);
expect(getByText(/replied to your tweet/)).toBeTruthy();
- });
- it('should render without content when tweet content is empty', () => {
- const notificationEmptyContent: TweetNotification = {
- ...mockNotification,
- tweetSummary: {
- ...mockNotification.tweetSummary,
- primaryTweet: {
- ...mockNotification.tweetSummary.primaryTweet,
- content: '',
- },
- },
- };
-
- const { getByText } = render(
-
-
-
- );
+ // Pressing avatar should not navigate when no primary actor
+ const avatarPressable = getByTestId('actor-avatar-pressable');
+ fireEvent.press(avatarPressable);
- expect(getByText(/Replier One replied to your tweet/)).toBeTruthy();
+ expect(mockRootNavigate).not.toHaveBeenCalledWith(ROOT.PROFILE, expect.anything());
});
});
diff --git a/src/__tests__/components/notifications/TweetRetweetNotificationItem.test.tsx b/src/__tests__/components/notifications/TweetRetweetNotificationItem.test.tsx
index b2972d830..16989ac9b 100644
--- a/src/__tests__/components/notifications/TweetRetweetNotificationItem.test.tsx
+++ b/src/__tests__/components/notifications/TweetRetweetNotificationItem.test.tsx
@@ -1,33 +1,36 @@
-import { render } from '@testing-library/react-native';
+import { useNavigation } from '@react-navigation/native';
+import { fireEvent, render } from '@testing-library/react-native';
import { TweetRetweetNotificationItem } from '@/components/notifications/types/TweetRetweetNotificationItem';
+import { useRootNavigation } from '@/hooks/navigation/useRootNavigation';
import { NotificationType, TweetNotification } from '@/types/notifications';
+import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames';
jest.mock('@/hooks/useTheme', () => ({
useTheme: () => ({ theme: 'light' }),
}));
-const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({
- navigate: mockNavigate,
- }),
+ useNavigation: jest.fn(() => ({
+ navigate: jest.fn(),
+ })),
}));
-const mockRootNavigate = jest.fn();
jest.mock('@/hooks/navigation/useRootNavigation', () => ({
- useRootNavigation: () => ({
- navigate: mockRootNavigate,
- }),
+ useRootNavigation: jest.fn(() => ({
+ navigate: jest.fn(),
+ })),
}));
-jest.mock('@/components/ui/ActorAvatarGroup', () => () => null);
+jest.mock('@/components/ui/ActorAvatarGroup', () => 'ActorAvatarGroup');
jest.mock('@/components/notifications/types/TweetActionBar', () => ({
- TweetActionBar: () => null,
+ TweetActionBar: 'TweetActionBar',
}));
describe('TweetRetweetNotificationItem', () => {
- const mockOnPress = jest.fn();
+ const mockOnPress = jest.fn((callback) => callback());
+ const mockNavigate = jest.fn();
+ const mockRootNavigate = jest.fn();
const createNotification = (overrides?: Partial): TweetNotification => ({
id: '1',
@@ -52,6 +55,13 @@ describe('TweetRetweetNotificationItem', () => {
username: 'author',
displayName: 'Author',
avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
content: 'Test tweet content',
createdAt: '2024-01-01T00:00:00Z',
@@ -74,16 +84,24 @@ describe('TweetRetweetNotificationItem', () => {
beforeEach(() => {
jest.clearAllMocks();
+ (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate });
+ (useRootNavigation as jest.Mock).mockReturnValue({ navigate: mockRootNavigate });
});
- it('renders single user retweet notification', () => {
+ it('renders correctly and handles main press', () => {
const notification = createNotification();
const { getByText } = render(
);
- expect(getByText(/User One/)).toBeTruthy();
+ expect(getByText('User One')).toBeTruthy();
expect(getByText(/retweeted your tweet/)).toBeTruthy();
+ expect(getByText('Test tweet content')).toBeTruthy();
+
+ fireEvent.press(getByText('Test tweet content'));
+
+ expect(mockOnPress).toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith(TWEET.DETAIL, { tweetId: 'tweet1' });
});
it('renders multiple users retweet notification', () => {
@@ -105,17 +123,8 @@ describe('TweetRetweetNotificationItem', () => {
);
- expect(getByText(/User One/)).toBeTruthy();
- expect(getByText(/4 others/)).toBeTruthy();
- });
-
- it('renders tweet content when available', () => {
- const notification = createNotification();
- const { getByText } = render(
-
- );
-
- expect(getByText(/Test tweet content/)).toBeTruthy();
+ expect(getByText('User One')).toBeTruthy();
+ expect(getByText(/and 4 others/)).toBeTruthy();
});
it('renders notification with media', () => {
@@ -127,6 +136,13 @@ describe('TweetRetweetNotificationItem', () => {
username: 'author',
displayName: 'Author',
avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
content: 'Test tweet',
createdAt: '2024-01-01T00:00:00Z',
@@ -154,27 +170,12 @@ describe('TweetRetweetNotificationItem', () => {
},
});
- const { getByText } = render(
+ const { getByText, getByTestId } = render(
);
expect(getByText(/Test tweet/)).toBeTruthy();
- });
-
- it('renders without errors', () => {
- const notification = createNotification();
- const { getByText } = render(
-
- );
-
- expect(getByText(/User One/)).toBeTruthy();
- });
-
- it('handles notification press correctly', () => {
- const notification = createNotification();
- render( );
-
- expect(mockOnPress).toBeDefined();
+ expect(getByTestId('tweet-media-image')).toBeTruthy();
});
it('handles mention press in tweet content', () => {
@@ -192,12 +193,27 @@ describe('TweetRetweetNotificationItem', () => {
render( );
});
- it('renders notification without content', () => {
+ it('handles case with no primary actor gracefully', () => {
+ const notification = createNotification({
+ actorSummary: {
+ previewActors: [],
+ totalCount: 0,
+ },
+ });
+
+ const { getByText } = render(
+
+ );
+
+ expect(getByText(/retweeted your tweet/)).toBeTruthy();
+ });
+
+ it('handles hashtag press in tweet content', () => {
const notification = createNotification({
tweetSummary: {
primaryTweet: {
...createNotification().tweetSummary.primaryTweet,
- content: '',
+ content: 'Test tweet with #hashtag',
},
totalCount: 1,
subjectIds: ['tweet1'],
@@ -208,22 +224,17 @@ describe('TweetRetweetNotificationItem', () => {
);
- expect(getByText(/User One/)).toBeTruthy();
- });
+ fireEvent.press(getByText('#hashtag'));
- it('handles actor press correctly', () => {
- const notification = createNotification();
- render( );
- expect(mockRootNavigate).toBeDefined();
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.SEARCH, {
+ initialQuery: '#hashtag',
+ initialTab: 'top',
+ });
});
- it('handles case with no primary actor gracefully', () => {
- const notification = createNotification({
- actorSummary: {
- previewActors: [],
- totalCount: 0,
- },
- });
+ it('renders without content when tweet content is empty', () => {
+ const notification = createNotification();
+ notification.tweetSummary.primaryTweet.content = '';
const { getByText } = render(
@@ -232,102 +243,72 @@ describe('TweetRetweetNotificationItem', () => {
expect(getByText(/retweeted your tweet/)).toBeTruthy();
});
- it('handles hashtag press in tweet content', () => {
- const notification = createNotification({
- tweetSummary: {
- primaryTweet: {
- id: 'tweet1',
- author: {
- username: 'author',
- displayName: 'Author',
- avatarUrl: null,
- },
- content: 'Test tweet with #hashtag',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 1,
- likeCount: 0,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- replyToTweetId: null,
- quoteToTweetId: null,
- quotedTweet: null,
- },
- totalCount: 1,
- subjectIds: ['tweet1'],
- },
+ it('navigates to user profile when actor avatar is pressed', () => {
+ const notification = createNotification();
+ const { getByTestId } = render(
+
+ );
+
+ // Press the avatar pressable wrapper which triggers handleActorPress
+ const avatarPressable = getByTestId('actor-avatar-pressable');
+ fireEvent.press(avatarPressable);
+
+ expect(mockOnPress).toHaveBeenCalled();
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: 'user1' },
});
+ });
+ it('renders seen styling', () => {
+ const notification = createNotification();
+ notification.isSeen = true;
render( );
- expect(mockRootNavigate).toBeDefined();
});
it('handles mention press in tweet content', () => {
const notification = createNotification({
tweetSummary: {
primaryTweet: {
- id: 'tweet1',
- author: {
- username: 'author',
- displayName: 'Author',
- avatarUrl: null,
- },
- content: 'Test tweet with @mention',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 1,
- likeCount: 0,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- replyToTweetId: null,
- quoteToTweetId: null,
- quotedTweet: null,
+ ...createNotification().tweetSummary.primaryTweet,
+ content: 'Tweet with @mentioneduser',
},
totalCount: 1,
subjectIds: ['tweet1'],
},
});
- render( );
- expect(mockRootNavigate).toBeDefined();
+ const { getByText } = render(
+
+ );
+
+ const mention = getByText('@mentioneduser');
+ fireEvent.press(mention);
+
+ expect(mockRootNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: 'mentioneduser' },
+ });
});
- it('renders without content when tweet content is empty', () => {
+ it('handles empty actors array gracefully', () => {
const notification = createNotification({
- tweetSummary: {
- primaryTweet: {
- id: 'tweet1',
- author: {
- username: 'author',
- displayName: 'Author',
- avatarUrl: null,
- },
- content: '',
- createdAt: '2024-01-01T00:00:00Z',
- replyCount: 0,
- retweetCount: 1,
- likeCount: 0,
- isLiked: false,
- isRetweeted: false,
- entities: { mentions: null, hashtags: null },
- media: null,
- replyToTweetId: null,
- quoteToTweetId: null,
- quotedTweet: null,
- },
- totalCount: 1,
- subjectIds: ['tweet1'],
+ actorSummary: {
+ previewActors: [],
+ totalCount: 0,
},
});
- const { getByText } = render(
+ const { getByText, getByTestId } = render(
);
- expect(getByText(/User One/)).toBeTruthy();
+ expect(getByText(/retweeted your tweet/)).toBeTruthy();
+
+ // Pressing avatar should not navigate when no primary actor
+ const avatarPressable = getByTestId('actor-avatar-pressable');
+ fireEvent.press(avatarPressable);
+
+ expect(mockRootNavigate).not.toHaveBeenCalledWith(ROOT.PROFILE, expect.anything());
});
});
diff --git a/src/__tests__/components/profile/Banner.test.tsx b/src/__tests__/components/profile/Banner.test.tsx
index 931068ea3..8443f3582 100644
--- a/src/__tests__/components/profile/Banner.test.tsx
+++ b/src/__tests__/components/profile/Banner.test.tsx
@@ -1,10 +1,11 @@
-import { ImageBackground, Share, View } from 'react-native';
+import { Alert, ImageBackground, Share, View } from 'react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import ProfileBanner from '@/components/profile/Banner';
import { mockUsers } from '@/mocks/data/userMock';
+import * as userStore from '@/stores/userStore';
const mockUsername = 'johndoe';
@@ -23,6 +24,10 @@ jest.mock('@/navigation/navigationRef', () => ({
},
}));
+jest.mock('@/hooks/profile/useBlockMutation');
+jest.mock('@/hooks/profile/useMuteMutation');
+jest.mock('@/stores/userStore');
+
const mockQueryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
@@ -38,11 +43,16 @@ jest.mock('@react-navigation/native', () => ({
useNavigation: () => mockNavigation,
}));
-describe('ProfileBanner', () => {
+describe('ProfileBanner Component', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Share, 'share').mockResolvedValue({ action: Share.sharedAction });
jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ (userStore.useUserStore as unknown as jest.Mock).mockImplementation(
+ (selector: (state: { user: { username: string } }) => unknown) =>
+ selector({ user: { username: 'different-user' } })
+ );
});
afterEach(() => {
@@ -227,7 +237,7 @@ describe('ProfileBanner', () => {
it('shares profile with all fallbacks when both are undefined', async () => {
const { getByTestId } = render(
-
+
);
@@ -345,3 +355,410 @@ describe('ProfileBanner', () => {
expect(searchButton).toBeTruthy();
});
});
+
+describe('Block and Mute functionality', () => {
+ const mockBlockMutate = jest.fn();
+ const mockMuteMutate = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ const mockUseBlockMutation = jest.requireMock('@/hooks/profile/useBlockMutation');
+ const mockUseMuteMutation = jest.requireMock('@/hooks/profile/useMuteMutation');
+
+ mockUseBlockMutation.useBlockMutation.mockReturnValue({
+ mutate: mockBlockMutate,
+ isPending: false,
+ });
+
+ mockUseMuteMutation.useMuteMutation.mockReturnValue({
+ mutate: mockMuteMutate,
+ isPending: false,
+ });
+
+ (userStore.useUserStore as unknown as jest.Mock).mockImplementation(
+ (selector: (state: { user: { username: string } }) => unknown) =>
+ selector({ user: { username: 'different-user' } })
+ );
+ });
+
+ it('shows block modal when block button is pressed', async () => {
+ const otherUserProfile = {
+ ...mockUsers[mockUsername],
+ username: 'otheruser',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ };
+
+ const { getByTestId, getByText } = render(
+
+
+
+ );
+
+ fireEvent.press(getByTestId('menu-dropdown-button'));
+
+ const blockButton = getByText('Block');
+ fireEvent.press(blockButton);
+
+ await waitFor(() => {
+ expect(getByTestId('backdrop-area')).toBeTruthy();
+ });
+ });
+
+ it('blocks a user when confirm is pressed in block modal', async () => {
+ const otherUserProfile = {
+ ...mockUsers[mockUsername],
+ username: 'otheruser',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ };
+
+ const { getByTestId, getByText } = render(
+
+
+
+ );
+
+ fireEvent.press(getByTestId('menu-dropdown-button'));
+
+ const blockButton = getByText('Block');
+ fireEvent.press(blockButton);
+
+ await waitFor(() => {
+ expect(getByTestId('backdrop-area')).toBeTruthy();
+ });
+
+ const confirmButton = getByTestId('dialog-confirm-button');
+ fireEvent.press(confirmButton);
+
+ await waitFor(() => {
+ expect(mockBlockMutate).toHaveBeenCalledWith(
+ {
+ username: 'otheruser',
+ block: true,
+ previous: false,
+ },
+ expect.any(Object)
+ );
+ });
+ });
+
+ it('mutes a user when mute button is pressed', async () => {
+ const otherUserProfile = {
+ ...mockUsers[mockUsername],
+ username: 'otheruser',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ };
+
+ const { getByTestId, getByText } = render(
+
+
+
+ );
+
+ fireEvent.press(getByTestId('menu-dropdown-button'));
+
+ const muteButton = getByText('Mute');
+ fireEvent.press(muteButton);
+
+ await waitFor(() => {
+ expect(getByTestId('backdrop-area')).toBeTruthy();
+ });
+
+ const confirmButton = getByTestId('dialog-confirm-button');
+ fireEvent.press(confirmButton);
+
+ await waitFor(() => {
+ expect(mockMuteMutate).toHaveBeenCalledWith(
+ {
+ username: 'otheruser',
+ mute: true,
+ previous: false,
+ },
+ expect.any(Object)
+ );
+ });
+ });
+
+ it('shows mute and block actions for other user profile', async () => {
+ const otherUserProfile = {
+ ...mockUsers[mockUsername],
+ username: 'otheruser',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ };
+
+ const { getByTestId, getByText } = render(
+
+
+
+ );
+
+ fireEvent.press(getByTestId('menu-dropdown-button'));
+
+ await waitFor(() => {
+ expect(getByText('Share')).toBeTruthy();
+ expect(getByText('Mute')).toBeTruthy();
+ expect(getByText('Block')).toBeTruthy();
+ });
+ });
+
+ it('shows only share action for your own profile', async () => {
+ (userStore.useUserStore as unknown as jest.Mock).mockImplementation(
+ (selector: (state: { user: { username: string } }) => unknown) =>
+ selector({ user: { username: 'johndoe' } })
+ );
+
+ const { getByTestId, getByText, queryByText } = render(
+
+
+
+ );
+
+ fireEvent.press(getByTestId('menu-dropdown-button'));
+
+ await waitFor(() => {
+ expect(getByText('Share')).toBeTruthy();
+ expect(queryByText('Mute')).toBeNull();
+ expect(queryByText('Block')).toBeNull();
+ });
+ });
+
+ it('does not show mute option for blocked user', async () => {
+ const blockedUserProfile = {
+ ...mockUsers[mockUsername],
+ username: 'blockeduser',
+ relationship: {
+ blocking: true,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ };
+
+ const { getByTestId, getByText, queryByText } = render(
+
+
+
+ );
+
+ fireEvent.press(getByTestId('menu-dropdown-button'));
+
+ await waitFor(() => {
+ expect(getByText('Share')).toBeTruthy();
+ expect(queryByText('Mute')).toBeNull();
+ expect(getByText('Unblock')).toBeTruthy();
+ });
+ });
+
+ it('shows unblock option for blocked user', async () => {
+ const blockedUserProfile = {
+ ...mockUsers[mockUsername],
+ username: 'blockeduser',
+ relationship: {
+ blocking: true,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ };
+
+ const { getByTestId, getByText } = render(
+
+
+
+ );
+
+ fireEvent.press(getByTestId('menu-dropdown-button'));
+
+ await waitFor(() => {
+ expect(getByText('Unblock')).toBeTruthy();
+ });
+
+ const unblockButton = getByText('Unblock');
+ fireEvent.press(unblockButton);
+
+ await waitFor(() => {
+ expect(getByTestId('backdrop-area')).toBeTruthy();
+ });
+
+ const confirmButton = getByTestId('dialog-confirm-button');
+ fireEvent.press(confirmButton);
+
+ await waitFor(() => {
+ expect(mockBlockMutate).toHaveBeenCalledWith(
+ {
+ username: 'blockeduser',
+ block: false,
+ previous: true,
+ },
+ expect.any(Object)
+ );
+ });
+ });
+
+ it('closes block modal before showing alert on block mutation error', async () => {
+ const mockUseBlockMutation = jest.requireMock('@/hooks/profile/useBlockMutation');
+
+ const otherUserProfile = {
+ ...mockUsers[mockUsername],
+ username: 'otheruser',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ };
+
+ const alertSpy = jest.spyOn(Alert, 'alert');
+
+ mockUseBlockMutation.useBlockMutation.mockReturnValue({
+ mutate: jest.fn((variables, options) => {
+ options?.onError({ message: 'Network error' });
+ }),
+ isPending: false,
+ });
+
+ const { getByTestId, getByText, queryByTestId } = render(
+
+
+
+ );
+
+ fireEvent.press(getByTestId('menu-dropdown-button'));
+ fireEvent.press(getByText('Block'));
+
+ await waitFor(() => {
+ expect(getByTestId('backdrop-area')).toBeTruthy();
+ });
+
+ const confirmButton = getByTestId('dialog-confirm-button');
+ fireEvent.press(confirmButton);
+
+ await waitFor(() => {
+ expect(queryByTestId('backdrop-area')).toBeNull();
+ expect(alertSpy).toHaveBeenCalledWith('Unable to unblock account', 'Network error');
+ });
+
+ alertSpy.mockRestore();
+ });
+
+ it('closes mute modal before showing alert on mute mutation error', async () => {
+ const mockUseMuteMutation = jest.requireMock('@/hooks/profile/useMuteMutation');
+
+ const otherUserProfile = {
+ ...mockUsers[mockUsername],
+ username: 'otheruser',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ };
+
+ const alertSpy = jest.spyOn(Alert, 'alert');
+
+ mockUseMuteMutation.useMuteMutation.mockReturnValue({
+ mutate: jest.fn((variables, options) => {
+ options?.onError({ message: 'Unable to reach server' });
+ }),
+ isPending: false,
+ });
+
+ const { getByTestId, getByText, queryByTestId } = render(
+
+
+
+ );
+
+ fireEvent.press(getByTestId('menu-dropdown-button'));
+ fireEvent.press(getByText('Mute'));
+
+ await waitFor(() => {
+ expect(getByTestId('backdrop-area')).toBeTruthy();
+ });
+
+ const confirmButton = getByTestId('dialog-confirm-button');
+ fireEvent.press(confirmButton);
+
+ await waitFor(() => {
+ expect(queryByTestId('backdrop-area')).toBeNull();
+ expect(alertSpy).toHaveBeenCalledWith('Unable to mute account', 'Unable to reach server');
+ });
+
+ alertSpy.mockRestore();
+ });
+});
diff --git a/src/__tests__/components/profile/FollowButton.test.tsx b/src/__tests__/components/profile/FollowButton.test.tsx
index c7101e126..2ea082521 100644
--- a/src/__tests__/components/profile/FollowButton.test.tsx
+++ b/src/__tests__/components/profile/FollowButton.test.tsx
@@ -282,7 +282,7 @@ describe('FollowButton', () => {
});
it('handles null isFollowing prop as false', () => {
- const { getByText } = render( );
+ const { getByText } = render( );
expect(getByText('Follow')).toBeTruthy();
});
@@ -301,7 +301,7 @@ describe('FollowButton', () => {
it('handles followsYou being null', () => {
const { getByText } = render(
-
+
);
expect(getByText('Follow')).toBeTruthy();
diff --git a/src/__tests__/components/profile/MuteButton.test.tsx b/src/__tests__/components/profile/MuteButton.test.tsx
new file mode 100644
index 000000000..9e63b7f3d
--- /dev/null
+++ b/src/__tests__/components/profile/MuteButton.test.tsx
@@ -0,0 +1,178 @@
+import { Alert } from 'react-native';
+
+import { fireEvent, render } from '@testing-library/react-native';
+
+import MuteButton from '@/components/profile/MuteButton';
+
+const mockMuteMutate = jest.fn();
+let mockMuteIsPending = false;
+
+jest.mock('@/hooks/profile/useMuteMutation', () => ({
+ useMuteMutation: () => ({
+ mutate: mockMuteMutate,
+ isPending: mockMuteIsPending,
+ }),
+}));
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'light' }),
+}));
+
+describe('MuteButton', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockMuteIsPending = false;
+ });
+
+ describe('Rendering Logic', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('shows unmuted icon when not muted and canMute is true', () => {
+ const { getByTestId } = render(
+
+ );
+
+ const muteButton = getByTestId('mute-button-unmuted');
+ expect(muteButton).toBeTruthy();
+ });
+
+ it('shows muted icon when isMuted is true', () => {
+ const { getByTestId } = render( );
+
+ const unmuteButton = getByTestId('mute-button-muted');
+ expect(unmuteButton).toBeTruthy();
+ });
+
+ it('does not show mute button when canMute is false and not muted', () => {
+ const { queryByTestId } = render(
+
+ );
+
+ expect(queryByTestId('mute-button-unmuted')).toBeNull();
+ expect(queryByTestId('mute-button-muted')).toBeNull();
+ });
+
+ it('shows unmute button even when canMute is false if user is already muted', () => {
+ const { getByTestId } = render(
+
+ );
+
+ const unmuteButton = getByTestId('mute-button-muted');
+ expect(unmuteButton).toBeTruthy();
+ });
+ });
+
+ describe('Mute Functionality', () => {
+ it('handles mute when unmuted button is pressed', () => {
+ const { getByTestId } = render(
+
+ );
+
+ const muteButton = getByTestId('mute-button-unmuted');
+ fireEvent.press(muteButton);
+
+ expect(mockMuteMutate).toHaveBeenCalledWith(
+ {
+ username: 'testuser',
+ mute: true,
+ previous: false,
+ },
+ expect.any(Object)
+ );
+ });
+
+ it('handles unmute when muted button is pressed', () => {
+ const { getByTestId } = render(
+
+ );
+
+ const unmuteButton = getByTestId('mute-button-muted');
+ fireEvent.press(unmuteButton);
+
+ expect(mockMuteMutate).toHaveBeenCalledWith(
+ {
+ username: 'testuser',
+ mute: false,
+ previous: true,
+ },
+ expect.any(Object)
+ );
+ });
+
+ it('does not call mutation if isPending is true', () => {
+ mockMuteIsPending = true;
+
+ const { getByTestId } = render(
+
+ );
+
+ const muteButton = getByTestId('mute-button-unmuted');
+ fireEvent.press(muteButton);
+
+ expect(mockMuteMutate).not.toHaveBeenCalled();
+ });
+
+ it('does not call mutation if previous state equals new state', () => {
+ const { getByTestId } = render(
+
+ );
+
+ const unmuteButton = getByTestId('mute-button-muted');
+ fireEvent.press(unmuteButton);
+
+ expect(mockMuteMutate).toHaveBeenCalled();
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('shows alert on mute error', () => {
+ const spyAlert = jest.spyOn(Alert, 'alert');
+
+ mockMuteMutate.mockImplementation(
+ (
+ args: { username: string; mute: boolean; previous: boolean },
+ options: { onError?: (error: { message?: string }) => void }
+ ) => {
+ options?.onError?.({ message: 'Network error' });
+ }
+ );
+
+ const { getByTestId } = render(
+
+ );
+
+ const muteButton = getByTestId('mute-button-unmuted');
+ fireEvent.press(muteButton);
+
+ expect(spyAlert).toHaveBeenCalledWith('Unable to mute account', 'Network error');
+
+ spyAlert.mockRestore();
+ });
+
+ it('shows default error message when error message is missing', () => {
+ const spyAlert = jest.spyOn(Alert, 'alert');
+
+ mockMuteMutate.mockImplementation(
+ (
+ args: { username: string; mute: boolean; previous: boolean },
+ options: { onError?: (error: Record) => void }
+ ) => {
+ options?.onError?.({});
+ }
+ );
+
+ const { getByTestId } = render(
+
+ );
+
+ const muteButton = getByTestId('mute-button-unmuted');
+ fireEvent.press(muteButton);
+
+ expect(spyAlert).toHaveBeenCalledWith('Unable to mute account', 'Please try again.');
+
+ spyAlert.mockRestore();
+ });
+ });
+});
diff --git a/src/__tests__/components/profile/ProfileHeader.test.tsx b/src/__tests__/components/profile/ProfileHeader.test.tsx
index 6fcb61548..9dd7bbfa1 100644
--- a/src/__tests__/components/profile/ProfileHeader.test.tsx
+++ b/src/__tests__/components/profile/ProfileHeader.test.tsx
@@ -16,16 +16,28 @@ import { mockUsers } from '@/mocks/data/userMock';
import { createOrGetConversationByUsername, dmKeys } from '@/services/dm';
import { useUserStore } from '@/stores/userStore';
import { Conversation } from '@/types/dm';
-import { PROFILE, ROOT } from '@/utils/navigation/routeNames';
+import { PROFILE, PROFILE_SETUP, ROOT } from '@/utils/navigation/routeNames';
const mockNavigate = jest.fn();
const mockPush = jest.fn();
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({
- navigate: mockNavigate,
- push: mockPush,
- }),
-}));
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return {
+ ...actual,
+ createNavigationContainerRef: () => ({
+ isReady: jest.fn(() => false),
+ getCurrentRoute: jest.fn(() => null),
+ navigate: jest.fn(),
+ dispatch: jest.fn(),
+ goBack: jest.fn(),
+ current: null,
+ }),
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ push: mockPush,
+ }),
+ };
+});
jest.mock('@/stores/userStore');
jest.mock('@/hooks/profile/useBlockMutation');
@@ -61,7 +73,7 @@ jest.mock('@/components/profile/ProfileInfo', () => {
);
});
-// Mock Stats to allow testing the click handlers passed to it
+
jest.mock('@/components/profile/Statistics', () => {
const { View, Button } = require('react-native');
return (props: any) => (
@@ -78,14 +90,14 @@ jest.mock('@/components/ui', () => ({
}));
jest.mock('@/components/profile/BlockModal', () => {
- // We simulate the modal being visible and having a confirm button
- const { View, Button } = require('react-native');
- return (props: any) =>
- props.modalVisible ? (
-
-
-
- ) : null;
+ const { View, Button, Text } = require('react-native');
+ return (props: any) => (
+
+ {props.modalVisible ? 'true' : 'false'}
+ props.setModalVisible(true)} />
+
+
+ );
});
jest.mock('@/components/profile/MuteModal', () => {
@@ -188,14 +200,38 @@ describe('ProfileHeader', () => {
});
});
- it('navigates to Edit Profile if avatar is default or missing', () => {
- const noPicProfile = { ...mockProfile, avatarUrl: null };
- renderWithClient( );
+ it('navigates to connections -> Mutuals when mutuals pressed', () => {
+ const profileWithMutuals = {
+ ...mockProfile,
+ mutualsCount: 3,
+ mutualUsers: [{ username: 'a' }, { username: 'b' }],
+ };
- fireEvent.press(screen.getByTestId('avatar-touchable'));
+ renderWithClient( );
- // Should go to edit page logic
- expect(mockPush).toHaveBeenCalledWith(PROFILE.EDIT, { username: 'testuser' });
+ fireEvent.press(screen.getByTestId('mutual-followers-button'));
+
+ expect(mockNavigate).toHaveBeenCalledWith(PROFILE.CONNECTIONS, {
+ username: 'testuser',
+ initialTab: 'Mutuals',
+ });
+ });
+
+ it('navigates to edit if profile is setup when edit button clicked', () => {
+ (useUserStore as unknown as jest.Mock).mockReturnValue('testuser');
+
+ const setupProfile = {
+ ...mockProfile,
+ avatarUrl: 'https://example.com/avatar.jpg',
+ bio: 'I am set up',
+ };
+
+ renderWithClient( );
+
+ const button = screen.getByTestId('edit-profile-button');
+ fireEvent.press(button);
+
+ expect(mockNavigate).toHaveBeenCalledWith(PROFILE.EDIT, { username: 'testuser' });
});
it('navigates to Banner view when banner is pressed', () => {
@@ -496,4 +532,152 @@ describe('ProfileHeader', () => {
expect(screen.queryByTestId('profile-message-button')).toBeNull();
});
});
+
+ describe('Profile Setup Navigation', () => {
+ it('navigates to profile setup when clicking own missing avatar', () => {
+ (useUserStore as unknown as jest.Mock).mockReturnValue('testuser');
+
+ const incompleteProfile = {
+ ...mockProfile,
+ avatarUrl: null,
+ bio: null,
+ bannerUrl: null,
+ };
+
+ renderWithClient( );
+
+ fireEvent.press(screen.getByTestId('avatar-touchable'));
+
+ expect(mockNavigate).toHaveBeenCalledWith(ROOT.PROFILE_SETUP, {
+ screen: PROFILE_SETUP.PHOTO,
+ });
+ });
+
+ it("doesn't navigate when viewing another user with a default/missing avatar", () => {
+ (useUserStore as unknown as jest.Mock).mockReturnValue('otheruser');
+
+ const otherProfile = {
+ ...mockProfile,
+ avatarUrl: null,
+ bannerUrl: null,
+ };
+
+ renderWithClient( );
+
+ fireEvent.press(screen.getByTestId('avatar-touchable'));
+
+ expect(mockPush).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('navigates to profile setup when clicking own missing banner', () => {
+ (useUserStore as unknown as jest.Mock).mockReturnValue('testuser');
+
+ const incompleteProfile = {
+ ...mockProfile,
+ avatarUrl: null,
+ bio: null,
+ bannerUrl: null,
+ };
+
+ renderWithClient( );
+
+ fireEvent.press(screen.getByTestId('banner-touchable'));
+
+ expect(mockNavigate).toHaveBeenCalledWith(ROOT.PROFILE_SETUP, {
+ screen: PROFILE_SETUP.PHOTO,
+ });
+ });
+
+ it('goes to setup when "Set up profile" button is pressed', () => {
+ (useUserStore as unknown as jest.Mock).mockReturnValue('testuser');
+
+ const incompleteProfile = {
+ ...mockProfile,
+ avatarUrl: null,
+ bio: null,
+ };
+
+ renderWithClient( );
+
+ const button = screen.getByTestId('edit-profile-button');
+ fireEvent.press(button);
+
+ expect(mockNavigate).toHaveBeenCalledWith(ROOT.PROFILE_SETUP, {
+ screen: PROFILE_SETUP.PHOTO,
+ });
+ });
+ });
+
+ describe('Unblock Mutation', () => {
+ beforeEach(() => {
+ (useUserStore as unknown as jest.Mock).mockReturnValue('otheruser');
+ });
+
+ it('calls unblock mutation when confirm pressed', async () => {
+ const blockedProfile = {
+ ...mockProfile,
+ relationship: { ...mockProfile.relationship, blocking: true },
+ };
+
+ renderWithClient( );
+
+ fireEvent.press(screen.getByText('Open Block Modal'));
+ expect(screen.getByTestId('block-modal-visible').props.children).toBe('true');
+ fireEvent.press(screen.getByText('Confirm Block'));
+
+ expect((useBlockMutation as jest.Mock).mock.results[0].value.mutate).toHaveBeenCalledWith(
+ {
+ username: 'testuser',
+ block: false,
+ previous: true,
+ },
+ expect.any(Object)
+ );
+ });
+
+ it('closes modal when confirm pressed', async () => {
+ const blockedProfile = {
+ ...mockProfile,
+ relationship: { ...mockProfile.relationship, blocking: true },
+ };
+
+ renderWithClient( );
+
+ fireEvent.press(screen.getByText('Open Block Modal'));
+
+ expect(screen.getByTestId('block-modal-visible').props.children).toBe('true');
+ fireEvent.press(screen.getByText('Confirm Block'));
+ await waitFor(() => {
+ expect(screen.getByTestId('block-modal-visible').props.children).toBe('false');
+ });
+ });
+
+ it('on block failure sets modal visible to false and shows alert', async () => {
+ const spyAlert = jest.spyOn(Alert, 'alert');
+ (useBlockMutation as jest.Mock).mockReturnValue({
+ mutate: (args: any, options: any) => {
+ options?.onError?.({ message: 'Block failed' });
+ },
+ isPending: false,
+ });
+
+ const blockedProfile = {
+ ...mockProfile,
+ relationship: { ...mockProfile.relationship, blocking: true },
+ };
+
+ renderWithClient( );
+
+ fireEvent.press(screen.getByText('Open Block Modal'));
+ expect(screen.getByTestId('block-modal-visible').props.children).toBe('true');
+ fireEvent.press(screen.getByText('Confirm Block'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('block-modal-visible').props.children).toBe('false');
+ });
+
+ expect(spyAlert).toHaveBeenCalledWith('Unable to unblock account', 'Block failed');
+ });
+ });
});
diff --git a/src/__tests__/components/profile/ProfileInfo.test.tsx b/src/__tests__/components/profile/ProfileInfo.test.tsx
index fcd0f1036..43b668170 100644
--- a/src/__tests__/components/profile/ProfileInfo.test.tsx
+++ b/src/__tests__/components/profile/ProfileInfo.test.tsx
@@ -1,317 +1,112 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-// @ts-nocheck
-/* eslint-disable @typescript-eslint/no-require-imports */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import { Linking } from 'react-native';
+
import { fireEvent, render } from '@testing-library/react-native';
-import ProfileInfo from '@/components/profile/ProfileInfo';
-import { mockUsers } from '@/mocks/data/userMock';
+import ProfileInfo from '../../../components/profile/ProfileInfo';
jest.mock('@expo/vector-icons', () => ({
Ionicons: 'Ionicons',
AntDesign: 'AntDesign',
}));
+jest.mock('@/hooks/profile/useBioParser', () => ({
+ useBioParser: jest.fn(({ bio }) => bio),
+}));
+
jest.mock('@/components/ui/AppText', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
const { Text } = require('react-native');
- return {
- __esModule: true,
- default: ({ children, variant, onPress }: any) =>
- onPress ? (
-
- {children}
-
- ) : (
- {children}
- ),
- };
+ const AppText = ({ children, ...props }: any) => {children} ;
+ AppText.displayName = 'AppText';
+ return AppText;
});
-const mockPush = jest.fn();
-
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({
- push: mockPush,
- }),
+jest.mock('@/components/utils/profile', () => ({
+ formatBirthDate: jest.fn((date) => `Born ${date}`),
+ formatJoinedAt: jest.fn((date) => `Joined ${date}`),
}));
-const mockUsername = 'johndoe';
-const mockProfile = mockUsers[mockUsername];
-
-describe('Profile Info Component', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('returns null when profile is null', () => {
- const { queryByTestId } = render( );
- expect(queryByTestId('profile-info')).toBeNull();
- });
-
- it('renders all child components when profile is provided', () => {
- const { getByTestId } = render( );
-
- expect(getByTestId('username-details')).toBeTruthy();
- expect(getByTestId('profile-details')).toBeTruthy();
- });
-
- it('renders bio text', () => {
- const { getAllByTestId } = render( );
-
- // Bio is split into parts with entities, so check for the presence of bio text
- const bioTexts = getAllByTestId('apptext-bio');
- const linkTexts = getAllByTestId('apptext-link');
- expect(bioTexts.length).toBeGreaterThan(0);
- expect(linkTexts.length).toBeGreaterThan(0);
- });
-
- it('does not render bio when bio is null', () => {
- const profileWithoutBio = { ...mockProfile, bio: null };
- const { queryByTestId } = render( );
-
- expect(queryByTestId('apptext-bio')).toBeNull();
- });
-
- it('does not render bio when bio is empty string', () => {
- const profileWithEmptyBio = { ...mockProfile, bio: '' };
- const { queryByTestId } = render( );
-
- expect(queryByTestId('apptext-bio')).toBeNull();
- });
-
- it('renders bio with mentions as links', () => {
- const profile = {
- ...mockProfile,
- bio: 'Hello @janedoe how are you?',
- bioEntities: {
- mentions: [{ username: 'janedoe', startPosition: 6 }],
- hashtags: [],
- },
- };
-
- const { getByText } = render( );
-
- expect(getByText('@janedoe')).toBeTruthy();
- });
-
- it('navigates to user profile when mention is clicked', () => {
- const mockOnPressMention = jest.fn();
- const profile = {
- ...mockProfile,
- bio: 'Hello @janedoe',
- bioEntities: {
- mentions: [{ username: 'janedoe', startPosition: 6 }],
- hashtags: [],
- },
- };
-
- const { getByText } = render(
-
- );
-
- const mention = getByText('@janedoe');
- fireEvent.press(mention);
-
- expect(mockOnPressMention).toHaveBeenCalledWith('janedoe');
- });
-
- it('renders bio with hashtags as links', () => {
- const profile = {
- ...mockProfile,
- bio: 'I love #Coding and #Tech',
- bioEntities: {
- mentions: [],
- hashtags: [
- { hashtag: 'Coding', startPosition: 7 },
- { hashtag: 'Tech', startPosition: 19 },
- ],
- },
- };
-
- const { getByText } = render( );
-
- expect(getByText('#Coding')).toBeTruthy();
- expect(getByText('#Tech')).toBeTruthy();
- });
-
- it('renders bio with mixed mentions and hashtags', () => {
- const profile = {
- ...mockProfile,
- bio: 'Hey @janedoe check #Coding stuff',
- bioEntities: {
- mentions: [{ username: 'janedoe', startPosition: 4 }],
- hashtags: [{ hashtag: 'Coding', startPosition: 19 }],
- },
- };
-
- const { getByText } = render( );
-
- expect(getByText('@janedoe')).toBeTruthy();
- expect(getByText('#Coding')).toBeTruthy();
- });
-
- it('handles bio with no entities', () => {
- const profile = {
- ...mockProfile,
- bio: 'Just plain text',
- bioEntities: {
- mentions: [],
- hashtags: [],
- },
- };
-
- const { getByText } = render( );
-
- expect(getByText('Just plain text')).toBeTruthy();
- });
-
- it('handles bio with undefined bioEntities', () => {
- const profile = {
- ...mockProfile,
- bio: 'Text without entities',
- bioEntities: undefined,
- };
-
- const { getByText } = render( );
-
- expect(getByText('Text without entities')).toBeTruthy();
- });
-
- it('handles bio with undefined mentions in bioEntities', () => {
- const profile = {
- ...mockProfile,
- bio: 'Text with hashtag #Test',
- bioEntities: {
- mentions: undefined,
- hashtags: [{ hashtag: 'Test', startPosition: 18 }],
- },
- };
+describe('ProfileInfo', () => {
+ const mockProfile = {
+ id: '1',
+ username: 'testuser',
+ displayName: 'Test User',
+ bio: 'This is a test bio',
+ avatar: 'avatar-url',
+ banner: 'banner-url',
+ followersCount: 100,
+ followingCount: 50,
+ postsCount: 10,
+ isFollowing: false,
+ isFollower: false,
+ location: 'Test City',
+ websiteUrl: 'https://example.com',
+ birthDate: '1990-01-01',
+ joinedAt: '2020-01-01',
+ bioEntities: {
+ mentions: [],
+ hashtags: [],
+ urls: [],
+ },
+ };
- const { getByText } = render( );
+ it('renders correctly with full profile data', () => {
+ const { getByText, getByTestId } = render( );
- expect(getByText('Text with hashtag ')).toBeTruthy();
- expect(getByText('#Test')).toBeTruthy();
+ expect(getByText('Test User')).toBeTruthy();
+ expect(getByText('@testuser')).toBeTruthy();
+ expect(getByText('This is a test bio')).toBeTruthy();
+ expect(getByText('Test City')).toBeTruthy();
+ expect(getByText('example.com')).toBeTruthy();
+ expect(getByText('Born 1990-01-01')).toBeTruthy();
+ expect(getByText('Joined 2020-01-01')).toBeTruthy();
});
- it('handles bio with undefined hashtags in bioEntities', () => {
- const profile = {
- ...mockProfile,
- bio: 'Text with mention @user',
- bioEntities: {
- mentions: [{ username: 'user', startPosition: 18 }],
- hashtags: undefined,
- },
+ it('renders correctly with minimal profile data', () => {
+ const minimalProfile = {
+ username: 'minimal',
+ displayName: 'Minimal User',
};
- const { getByText } = render( );
+ const { getByText, queryByText } = render( );
- expect(getByText('Text with mention ')).toBeTruthy();
- expect(getByText('@user')).toBeTruthy();
- });
-
- it('passes correct data to ProfileUser', () => {
- const { getByText } = render( );
+ expect(getByText('Minimal User')).toBeTruthy();
+ expect(getByText('@minimal')).toBeTruthy();
- expect(getByText('@johndoe')).toBeTruthy();
+ expect(queryByText('Test City')).toBeNull();
+ expect(queryByText('example.com')).toBeNull();
+ expect(queryByText('Born')).toBeNull();
+ expect(queryByText('Joined')).toBeNull();
});
- it('passes correct data to ProfileDetails', () => {
- const { getByText } = render( );
-
- expect(getByText('New York, USA')).toBeTruthy();
- });
+ it('handles external link clicks', async () => {
+ const spyOpenURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true);
+ const spyCanOpen = jest.spyOn(Linking, 'canOpenURL').mockResolvedValue(true);
- it('handles bio entities at the start of the bio', () => {
- const profile = {
- ...mockProfile,
- bio: '@janedoe hello',
- bioEntities: {
- mentions: [{ username: 'janedoe', startPosition: 0 }],
- hashtags: [],
- },
- };
+ const { getByText } = render( );
- const { getByText } = render( );
+ const link = getByText('example.com');
+ fireEvent.press(link);
- expect(getByText('@janedoe')).toBeTruthy();
- expect(getByText(' hello')).toBeTruthy();
+ expect(spyCanOpen).toHaveBeenCalledWith('https://example.com');
});
- it('handles bio entities at the end of the bio', () => {
- const profile = {
+ it('truncates long website URLs', () => {
+ const longProfile = {
...mockProfile,
- bio: 'hello @janedoe',
- bioEntities: {
- mentions: [{ username: 'janedoe', startPosition: 6 }],
- hashtags: [],
- },
+ websiteUrl:
+ 'https://www.verylongdomainnamethatshouldbetruncatedbecauseitistoolong.com/some/path',
};
- const { getByText } = render( );
-
- expect(getByText('hello ')).toBeTruthy();
- expect(getByText('@janedoe')).toBeTruthy();
+ const { getByText } = render( );
+ expect(getByText(/verylongdomainname/)).toBeTruthy();
+ expect(getByText(/\.\.\.$/)).toBeTruthy();
});
- it('sorts entities by start position', () => {
- const profile = {
- ...mockProfile,
- bio: 'Check #Tech and @janedoe',
- bioEntities: {
- mentions: [{ username: 'janedoe', startPosition: 16 }],
- hashtags: [{ hashtag: 'Tech', startPosition: 6 }],
- },
- };
-
- const { getByText } = render( );
-
- expect(getByText('#Tech')).toBeTruthy();
- expect(getByText('@janedoe')).toBeTruthy();
- });
-
- it('truncates website display but opens full URL', () => {
- const websiteUrl =
- 'https://averylongdomain.com/some/really/long/path/that/should/truncate/for/display';
- const profile = {
- ...mockProfile,
- websiteUrl,
- };
- const { queryAllByTestId } = render( );
-
- const expectedDisplay = (() => {
- const noProto = websiteUrl.replace(/^https?:\/\//, '');
- const max = 40;
- return noProto.length <= max ? noProto : `${noProto.slice(0, max - 3)}...`;
- })();
-
- const linkTexts = queryAllByTestId('apptext-link');
- const websiteNode = linkTexts.find((n: any) => String(n.props.children) === expectedDisplay);
- expect(websiteNode).toBeTruthy();
- expect(String(websiteNode!.props.children).length).toBeLessThanOrEqual(40);
- });
-
- it('calls handlers for mention and hashtag presses', () => {
- const mockOnPressMention = jest.fn();
- const mockOnPressHashtag = jest.fn();
- const profile = {
- ...mockProfile,
- bio: 'Ping @janedoe and #Topic',
- bioEntities: {
- mentions: [{ username: 'janedoe', startPosition: 5 }],
- hashtags: [{ hashtag: 'Topic', startPosition: 20 }],
- },
- };
- const { getByText } = render(
-
- );
- const mention = getByText('@janedoe');
- const hashtag = getByText('#Topic');
- fireEvent.press(mention);
- fireEvent.press(hashtag);
- expect(mockOnPressMention).toHaveBeenCalledWith('janedoe');
- expect(mockOnPressHashtag).toHaveBeenCalledWith('#Topic');
+ it('renders null if profile is null', () => {
+ const { toJSON } = render( );
+ expect(toJSON()).toBeNull();
});
});
diff --git a/src/__tests__/components/search/SearchPeopleResults.test.tsx b/src/__tests__/components/search/SearchPeopleResults.test.tsx
index c39555e56..e43c8046b 100644
--- a/src/__tests__/components/search/SearchPeopleResults.test.tsx
+++ b/src/__tests__/components/search/SearchPeopleResults.test.tsx
@@ -83,12 +83,25 @@ jest.mock('@/components/profile/ConnectionListItem', () => {
};
});
-jest.mock('@react-navigation/native', () => ({
- useNavigation: jest.fn(),
-}));
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return {
+ ...actual,
+ createNavigationContainerRef: () => ({
+ isReady: jest.fn(() => false),
+ getCurrentRoute: jest.fn(() => null),
+ navigate: jest.fn(),
+ dispatch: jest.fn(),
+ goBack: jest.fn(),
+ current: null,
+ }),
+ useNavigation: jest.fn(),
+ };
+});
const { useNavigation } = jest.requireMock('@react-navigation/native');
const mockNavigate = jest.fn();
+const mockPush = jest.fn();
describe('SearchPeopleResults', () => {
beforeEach(() => {
@@ -100,7 +113,7 @@ describe('SearchPeopleResults', () => {
useUserStore.getState().setUser({ ...current, username: 'current-user' });
});
- (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate });
+ (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate, push: mockPush });
});
afterEach(() => {
@@ -247,7 +260,7 @@ describe('SearchPeopleResults', () => {
expect(getByTestId('connection-khaled')).toBeTruthy();
fireEvent.press(getByTestId('profile-ahmed'));
- expect(mockNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ expect(mockPush).toHaveBeenCalledWith(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username: 'ahmed' },
});
diff --git a/src/__tests__/components/search/SearchSuggestions.test.tsx b/src/__tests__/components/search/SearchSuggestions.test.tsx
index 2a2c11bd9..b08ac6425 100644
--- a/src/__tests__/components/search/SearchSuggestions.test.tsx
+++ b/src/__tests__/components/search/SearchSuggestions.test.tsx
@@ -56,26 +56,24 @@ describe('SearchSuggestions', () => {
const onSuggestionPress = jest.fn();
const onGoToProfile = jest.fn();
- mockUseQuery
- .mockReturnValueOnce({ data: [{ hashtag: 'news', usageCount: 1200 }], isFetching: false })
- .mockReturnValueOnce({
- data: [
- {
- username: 'test',
- displayName: 'Test',
- avatarUrl: null,
- bannerUrl: null,
- bio: null,
- relationship: {
- following: false,
- follower: true,
- blocking: false,
- blockedBy: false,
- },
+ mockUseQuery.mockReturnValueOnce({ data: ['#news'], isFetching: false }).mockReturnValueOnce({
+ data: [
+ {
+ username: 'test',
+ displayName: 'Test',
+ avatarUrl: null,
+ bannerUrl: null,
+ bio: null,
+ relationship: {
+ following: false,
+ follower: true,
+ blocking: false,
+ blockedBy: false,
},
- ],
- isFetching: false,
- });
+ },
+ ],
+ isFetching: false,
+ });
const { getByText } = render(
({
jest.mock('@/navigation/navigationRef', () => ({
navigationRef: {
getCurrentRoute: jest.fn(),
+ isReady: jest.fn(() => false),
},
}));
const mockNavigate = jest.fn();
+const mockPush = jest.fn();
const mockSetParams = jest.fn();
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({ navigate: mockNavigate, setParams: mockSetParams }),
- useRoute: () => ({ params: { initialQuery: 'test', initialTab: 'top' } }),
-}));
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return {
+ ...actual,
+ createNavigationContainerRef: () => ({
+ isReady: jest.fn(() => false),
+ getCurrentRoute: jest.fn(() => null),
+ navigate: jest.fn(),
+ dispatch: jest.fn(),
+ goBack: jest.fn(),
+ current: null,
+ }),
+ useNavigation: () => ({ navigate: mockNavigate, push: mockPush, setParams: mockSetParams }),
+ useRoute: () => ({ params: { initialQuery: 'test', initialTab: 'top' } }),
+ };
+});
jest.mock('@/hooks/useTheme', () => ({
useTheme: () => ({ theme: 'light' }),
@@ -70,11 +84,13 @@ describe('SearchTopPeople', () => {
avatarUrl: 'https://example.com/a.jpg',
bannerUrl: 'https://example.com/b.jpg',
bio: 'Bad',
+ bioEntities: null,
relationship: {
- following: null,
- follower: null,
+ following: undefined,
+ follower: undefined,
blocking: false,
blockedBy: false,
+ muted: false,
},
},
];
@@ -89,7 +105,7 @@ describe('SearchTopPeople', () => {
});
fireEvent.press(getByTestId('search-top-people-card-cristiano'));
- expect(mockNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ expect(mockPush).toHaveBeenCalledWith(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username: 'cristiano' },
});
diff --git a/src/__tests__/components/search/SearchTweetResults.test.tsx b/src/__tests__/components/search/SearchTweetResults.test.tsx
index 3cd2a1e32..bdc715348 100644
--- a/src/__tests__/components/search/SearchTweetResults.test.tsx
+++ b/src/__tests__/components/search/SearchTweetResults.test.tsx
@@ -30,9 +30,13 @@ jest.mock('@/stores/searchFiltersStore', () => ({
const mockUseInfiniteQuery = jest.fn();
const mockUseQuery = jest.fn();
+const mockQueryClient = { getQueryData: jest.fn(), setQueryData: jest.fn() };
+const mockUseQueryClient = jest.fn(() => mockQueryClient);
+
jest.mock('@tanstack/react-query', () => ({
useInfiniteQuery: (...args: unknown[]) => mockUseInfiniteQuery(...args),
useQuery: (...args: unknown[]) => mockUseQuery(...args),
+ useQueryClient: () => mockUseQueryClient(),
}));
const mockUseFocusEffect = jest.fn((cb: () => void | (() => void)) => {
diff --git a/src/__tests__/components/settings/SettingsSections.test.tsx b/src/__tests__/components/settings/SettingsSections.test.tsx
index 41187f44c..3ea04342d 100644
--- a/src/__tests__/components/settings/SettingsSections.test.tsx
+++ b/src/__tests__/components/settings/SettingsSections.test.tsx
@@ -2,7 +2,6 @@ import { render } from '@testing-library/react-native';
import {
AccountInfoSettingsCard,
- NotificationsSettingsCard,
PrivacySettingsCard,
} from '@/components/settings/SettingsSections';
import { ThemeProvider } from '@/hooks/useTheme';
@@ -37,14 +36,4 @@ describe('SettingsSections Components', () => {
expect(toJSON()).toMatchSnapshot();
});
});
-
- describe('NotificationsSettingsCard', () => {
- it('renders correctly', () => {
- const { toJSON, getByText } = renderWithTheme( );
-
- expect(getByText('Notifications')).toBeTruthy();
- expect(getByText('Manage your notification preferences and settings.')).toBeTruthy();
- expect(toJSON()).toMatchSnapshot();
- });
- });
});
diff --git a/src/__tests__/components/ui/BioInput.test.tsx b/src/__tests__/components/ui/BioInput.test.tsx
index cb1f6fc64..d27baa268 100644
--- a/src/__tests__/components/ui/BioInput.test.tsx
+++ b/src/__tests__/components/ui/BioInput.test.tsx
@@ -6,6 +6,16 @@ jest.mock('@/hooks/useTheme', () => ({
useTheme: () => ({ theme: 'light' }),
}));
+// Mock colors to test styling logic deterministically
+jest.mock('@/utils/colorTheme', () => ({
+ colors: {
+ light: {
+ destructive: '#ff0000',
+ mutedForeground: '#999999',
+ },
+ },
+}));
+
describe('BioInput', () => {
it('renders with label and character count', () => {
const { getByText, getByTestId } = render(
@@ -49,4 +59,72 @@ describe('BioInput', () => {
const { getByTestId } = render( );
expect(getByTestId('bio-input').props.editable).toBe(false);
});
+
+ it('updates input height based on content size change and clamps to limits', () => {
+ const { getByTestId } = render( );
+ const input = getByTestId('bio-input');
+
+ // Constants from BioInput.tsx: LINE_HEIGHT = 24, MIN_LINES = 4, MAX_LINES = 6
+ // Padding logic: Platform.OS === 'ios' ? 16 : 20.
+
+ // 1. Initial state check (MIN_LINES)
+ // Height should be roughly 4 * 24 + 16/20 = 112/116
+ let style = input.props.style;
+ // We check if it's within the range of possible default heights to account for Platform differences
+ expect(style.height).toBeGreaterThanOrEqual(112);
+ expect(style.height).toBeLessThanOrEqual(116);
+
+ // 2. Grow within bounds (5 lines)
+ // 5 * 24 = 120 content height
+ fireEvent(input, 'contentSizeChange', {
+ nativeEvent: { contentSize: { width: 300, height: 120 } },
+ });
+ style = input.props.style;
+ // Expected: 5 * 24 + 16/20 = 136/140
+ expect(style.height).toBeGreaterThanOrEqual(136);
+ expect(style.height).toBeLessThanOrEqual(140);
+
+ // 3. Exceed Max (10 lines)
+ // 10 * 24 = 240 content height
+ fireEvent(input, 'contentSizeChange', {
+ nativeEvent: { contentSize: { width: 300, height: 240 } },
+ });
+ style = input.props.style;
+ // Should clamp to MAX_LINES (6)
+ // 6 * 24 + 16/20 = 160/164
+ expect(style.height).toBeGreaterThanOrEqual(160);
+ expect(style.height).toBeLessThanOrEqual(164);
+
+ // 4. Below Min (1 line)
+ // 1 * 24 = 24 content height
+ fireEvent(input, 'contentSizeChange', {
+ nativeEvent: { contentSize: { width: 300, height: 24 } },
+ });
+ style = input.props.style;
+ // Should clamp to MIN_LINES (4)
+ expect(style.height).toBeGreaterThanOrEqual(112);
+ expect(style.height).toBeLessThanOrEqual(116);
+ });
+
+ it('changes character count color when max length is reached', () => {
+ const { getByText, rerender } = render(
+
+ );
+
+ // Case 1: Under limit
+ const counterUnder = getByText('4/5');
+ // Expect mutedForeground color from mock
+ expect(counterUnder.props.style).toEqual(expect.objectContaining({ color: '#999999' }));
+
+ // Case 2: At limit
+ rerender( );
+ const counterLimit = getByText('5/5');
+ // Expect destructive color from mock
+ expect(counterLimit.props.style).toEqual(expect.objectContaining({ color: '#ff0000' }));
+
+ // Case 3: Over limit (checking style logic handles it)
+ rerender( );
+ const counterOver = getByText('6/5');
+ expect(counterOver.props.style).toEqual(expect.objectContaining({ color: '#ff0000' }));
+ });
});
diff --git a/src/__tests__/components/ui/ConfirmationModal.test.tsx b/src/__tests__/components/ui/ConfirmationModal.test.tsx
new file mode 100644
index 000000000..daad0d1d4
--- /dev/null
+++ b/src/__tests__/components/ui/ConfirmationModal.test.tsx
@@ -0,0 +1,198 @@
+import { Platform } from 'react-native';
+
+import { fireEvent, render, screen } from '@testing-library/react-native';
+
+import { ButtonVariant } from '@/components/ui/Button';
+import ConfirmationModal from '@/components/ui/ConfirmationModal';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'light' as const }),
+}));
+
+describe('ConfirmationModal', () => {
+ const defaultProps = {
+ modalVisible: true,
+ setModalVisible: jest.fn(),
+ handleConfirm: jest.fn(),
+ title: 'Test Title',
+ description: 'Test Description',
+ confirmText: 'Confirm',
+ confirmVariant: 'destructive' as ButtonVariant,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Android Platform', () => {
+ beforeEach(() => {
+ Platform.OS = 'android';
+ });
+
+ it('renders ModalOverlay on Android', () => {
+ render( );
+
+ expect(screen.queryByTestId('dialog-content')).toBeNull();
+ });
+
+ it('renders correct title and description on Android', () => {
+ render( );
+
+ expect(screen.getByText('Test Title')).toBeTruthy();
+ expect(screen.getByText('Test Description')).toBeTruthy();
+ });
+
+ it('renders confirm and cancel buttons on Android', () => {
+ render( );
+
+ expect(screen.getByTestId('block-button')).toBeTruthy();
+ expect(screen.getByTestId('cancel-block-button')).toBeTruthy();
+ expect(screen.getByText('Confirm')).toBeTruthy();
+ expect(screen.getByText('Cancel')).toBeTruthy();
+ });
+
+ it('does not render ModalOverlay when modalVisible is false on Android', () => {
+ render( );
+
+ expect(screen.queryByText('Test Title')).toBeNull();
+ });
+
+ it('renders with custom testID on Android', () => {
+ render( );
+
+ expect(screen.getByTestId('custom-modal')).toBeTruthy();
+ });
+
+ it('calls handleConfirm when confirm button is pressed on Android', () => {
+ const handleConfirm = jest.fn();
+ render( );
+
+ fireEvent.press(screen.getByTestId('block-button'));
+
+ expect(handleConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls setModalVisible(false) when cancel button is pressed on Android', () => {
+ const setModalVisible = jest.fn();
+ render( );
+
+ fireEvent.press(screen.getByTestId('cancel-block-button'));
+
+ expect(setModalVisible).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('iOS Platform', () => {
+ beforeEach(() => {
+ Platform.OS = 'ios';
+ });
+
+ it('renders ConfirmationDialog on iOS', () => {
+ render( );
+
+ expect(screen.getByTestId('dialog-content')).toBeTruthy();
+ });
+
+ it('renders correct title and message on iOS', () => {
+ render( );
+
+ expect(screen.getByTestId('dialog-title')).toBeTruthy();
+ expect(screen.getByTestId('dialog-message')).toBeTruthy();
+ expect(screen.getByText('Test Title')).toBeTruthy();
+ expect(screen.getByText('Test Description')).toBeTruthy();
+ });
+
+ it('renders correct confirm text on iOS', () => {
+ render( );
+
+ const confirmButton = screen.getByTestId('dialog-confirm-button');
+ expect(confirmButton).toBeTruthy();
+ expect(screen.getByText('Confirm')).toBeTruthy();
+ });
+
+ it('does not render ConfirmationDialog when modalVisible is false on iOS', () => {
+ render( );
+
+ expect(screen.queryByTestId('dialog-content')).toBeNull();
+ });
+
+ it('calls handleConfirm when confirm is pressed on iOS', () => {
+ const handleConfirm = jest.fn();
+ render( );
+
+ fireEvent.press(screen.getByTestId('dialog-confirm-button'));
+
+ expect(handleConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls setModalVisible(false) when cancel is pressed on iOS', () => {
+ const setModalVisible = jest.fn();
+ render( );
+
+ fireEvent.press(screen.getByTestId('dialog-cancel-button'));
+
+ expect(setModalVisible).toHaveBeenCalledWith(false);
+ });
+
+ it('calls handleCancel when provided and cancel is pressed on iOS', () => {
+ const handleCancel = jest.fn();
+ const setModalVisible = jest.fn();
+ render(
+
+ );
+
+ fireEvent.press(screen.getByTestId('dialog-cancel-button'));
+
+ expect(handleCancel).toHaveBeenCalledTimes(1);
+ expect(setModalVisible).toHaveBeenCalledWith(false);
+ });
+
+ it('does not throw when handleCancel is not provided and cancel is pressed on iOS', () => {
+ const setModalVisible = jest.fn();
+ render( );
+
+ expect(() => {
+ fireEvent.press(screen.getByTestId('dialog-cancel-button'));
+ }).not.toThrow();
+
+ expect(setModalVisible).toHaveBeenCalledWith(false);
+ });
+
+ it('can close dialog by pressing backdrop on iOS', () => {
+ const setModalVisible = jest.fn();
+ const handleCancel = jest.fn();
+ render(
+
+ );
+
+ fireEvent.press(screen.getByTestId('backdrop-area'));
+
+ expect(handleCancel).toHaveBeenCalledTimes(1);
+ expect(setModalVisible).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('Platform-specific rendering', () => {
+ it('only renders Android component on Android platform', () => {
+ Platform.OS = 'android';
+ const { rerender } = render( );
+
+ expect(screen.queryByTestId('dialog-content')).toBeNull();
+ expect(screen.getByText('Test Title')).toBeTruthy();
+
+ Platform.OS = 'ios';
+ rerender( );
+
+ expect(screen.getByTestId('dialog-content')).toBeTruthy();
+ expect(screen.getByText('Test Title')).toBeTruthy();
+ });
+ });
+});
diff --git a/src/__tests__/components/ui/NewTweetsIndicator.test.tsx b/src/__tests__/components/ui/NewTweetsIndicator.test.tsx
new file mode 100644
index 000000000..48f1cffbf
--- /dev/null
+++ b/src/__tests__/components/ui/NewTweetsIndicator.test.tsx
@@ -0,0 +1,149 @@
+import { fireEvent, render } from '@testing-library/react-native';
+
+import NewTweetsIndicator from '@/components/ui/NewTweetsIndicator';
+import { ThemeProvider } from '@/hooks/useTheme';
+
+jest.mock('@/hooks/useTheme', () => {
+ const actual = jest.requireActual('@/hooks/useTheme');
+ return {
+ ...actual,
+ useTheme: () => ({ theme: 'light', setTheme: jest.fn() }),
+ ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ };
+});
+
+jest.mock('expo-image', () => ({
+ Image: 'Image',
+}));
+
+jest.mock('react-native-reanimated', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { View, Pressable } = require('react-native');
+ return {
+ default: {
+ createAnimatedComponent: (Component: React.ComponentType) => Component,
+ },
+ createAnimatedComponent: (Component: React.ComponentType) => {
+ if (Component.displayName === 'Pressable' || Component === Pressable) {
+ return Pressable;
+ }
+ return Component;
+ },
+ FadeIn: { duration: () => ({}) },
+ FadeOut: { duration: () => ({}) },
+ View,
+ };
+});
+
+const renderWithProviders = (component: React.ReactElement) => {
+ return render({component} );
+};
+
+describe('NewTweetsIndicator', () => {
+ const mockOnPress = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly with single avatar', () => {
+ const { getByTestId } = renderWithProviders(
+
+ );
+
+ expect(getByTestId('new-tweets-indicator')).toBeTruthy();
+ });
+
+ it('renders correctly with multiple avatars', () => {
+ const { getByTestId } = renderWithProviders(
+
+ );
+
+ expect(getByTestId('new-tweets-indicator')).toBeTruthy();
+ });
+
+ it('renders correctly with empty avatar array', () => {
+ const { getByTestId } = renderWithProviders(
+
+ );
+
+ expect(getByTestId('new-tweets-indicator')).toBeTruthy();
+ });
+
+ it('calls onPress when pressed', () => {
+ const { getByTestId } = renderWithProviders(
+
+ );
+
+ fireEvent.press(getByTestId('new-tweets-indicator'));
+
+ expect(mockOnPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders without testID prop', () => {
+ const { toJSON } = renderWithProviders(
+
+ );
+
+ expect(toJSON()).toBeTruthy();
+ });
+
+ it('handles many avatars (more than 3)', () => {
+ const manyAvatars = [
+ 'https://example.com/avatar1.jpg',
+ 'https://example.com/avatar2.jpg',
+ 'https://example.com/avatar3.jpg',
+ 'https://example.com/avatar4.jpg',
+ 'https://example.com/avatar5.jpg',
+ ];
+
+ const { getByTestId } = renderWithProviders(
+
+ );
+
+ expect(getByTestId('new-tweets-indicator')).toBeTruthy();
+ });
+
+ it('matches snapshot with avatars', () => {
+ const { toJSON } = renderWithProviders(
+
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('matches snapshot with no avatars', () => {
+ const { toJSON } = renderWithProviders(
+
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/src/__tests__/components/ui/Suggestions.test.tsx b/src/__tests__/components/ui/Suggestions.test.tsx
new file mode 100644
index 000000000..b483d0352
--- /dev/null
+++ b/src/__tests__/components/ui/Suggestions.test.tsx
@@ -0,0 +1,139 @@
+import { render, waitFor } from '@testing-library/react-native';
+
+import Suggestions from '@/components/ui/Suggestions';
+import * as searchService from '@/services/search';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'dark' }),
+}));
+
+jest.mock('@/services/search');
+
+describe('Suggestions', () => {
+ const mockOnSelect = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns null when no keyword provided', () => {
+ const { toJSON } = render( );
+ expect(toJSON()).toBeNull();
+ });
+
+ it('returns null when keyword is empty', () => {
+ const { toJSON } = render( );
+ expect(toJSON()).toBeNull();
+ });
+
+ it('returns null when suggestions array is empty', async () => {
+ (searchService.searchUsersSuggestions as jest.Mock).mockResolvedValue({
+ data: { users: [] },
+ });
+
+ const { toJSON } = render( );
+
+ // Wait for the API call to complete
+ await waitFor(() => {
+ expect(searchService.searchUsersSuggestions).toHaveBeenCalledWith({ query: 'test' });
+ });
+
+ expect(toJSON()).toBeNull();
+ });
+
+ it('renders suggestions when keyword and results exist', async () => {
+ (searchService.searchUsersSuggestions as jest.Mock).mockResolvedValue({
+ data: {
+ users: [
+ { username: 'user1', displayName: 'User One', avatarUrl: null },
+ {
+ username: 'user2',
+ displayName: 'User Two',
+ avatarUrl: 'https://example.com/avatar.jpg',
+ },
+ ],
+ },
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(searchService.searchUsersSuggestions).toHaveBeenCalledWith({ query: 'user' });
+ });
+ });
+
+ it('calls onSelect with correct data when suggestion is pressed', async () => {
+ (searchService.searchUsersSuggestions as jest.Mock).mockResolvedValue({
+ data: {
+ users: [{ username: 'testuser', displayName: 'Test User', avatarUrl: null }],
+ },
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(searchService.searchUsersSuggestions).toHaveBeenCalled();
+ });
+ });
+
+ it('handles API errors gracefully', async () => {
+ (searchService.searchUsersSuggestions as jest.Mock).mockRejectedValue(
+ new Error('Network error')
+ );
+
+ const { toJSON } = render( );
+
+ await waitFor(() => {
+ expect(searchService.searchUsersSuggestions).toHaveBeenCalled();
+ });
+
+ // Should not throw and should render null due to empty suggestions
+ expect(toJSON()).toBeNull();
+ });
+
+ it('respects maxItems prop', async () => {
+ const manyUsers = Array.from({ length: 20 }, (_, i) => ({
+ username: `user${i}`,
+ displayName: `User ${i}`,
+ avatarUrl: null,
+ }));
+
+ (searchService.searchUsersSuggestions as jest.Mock).mockResolvedValue({
+ data: { users: manyUsers },
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(searchService.searchUsersSuggestions).toHaveBeenCalled();
+ });
+ });
+
+ it('renders with top position', async () => {
+ (searchService.searchUsersSuggestions as jest.Mock).mockResolvedValue({
+ data: {
+ users: [{ username: 'user1', displayName: 'User One', avatarUrl: null }],
+ },
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(searchService.searchUsersSuggestions).toHaveBeenCalled();
+ });
+ });
+
+ it('renders with bottom position (default)', async () => {
+ (searchService.searchUsersSuggestions as jest.Mock).mockResolvedValue({
+ data: {
+ users: [{ username: 'user1', displayName: 'User One', avatarUrl: null }],
+ },
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(searchService.searchUsersSuggestions).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/__tests__/components/ui/TimelineFeedList.test.tsx b/src/__tests__/components/ui/TimelineFeedList.test.tsx
index 2d3a5c3ae..dce555e9e 100644
--- a/src/__tests__/components/ui/TimelineFeedList.test.tsx
+++ b/src/__tests__/components/ui/TimelineFeedList.test.tsx
@@ -109,12 +109,25 @@ jest.mock('@/components/ui/TweetComposer', () => ({
},
}));
-const mockRootNavigation = { navigate: jest.fn() };
+const mockRootNavigation = { navigate: jest.fn(), push: jest.fn() };
const mockUseNavigation = jest.fn();
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => mockUseNavigation(),
-}));
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return {
+ ...actual,
+ createNavigationContainerRef: () => ({
+ isReady: jest.fn(() => false),
+ getCurrentRoute: jest.fn(() => null),
+ navigate: jest.fn(),
+ dispatch: jest.fn(),
+ goBack: jest.fn(),
+ current: null,
+ }),
+ useNavigation: () => mockUseNavigation(),
+ useScrollToTop: jest.fn(),
+ };
+});
const createTweet = (overrides: Partial = {}): UITweet => ({
id: overrides.id ?? 'tweet-id',
@@ -172,7 +185,18 @@ type ComponentOverrides = Partial<{
onOpenTweetDetail?: (tweetId: string) => void;
emptyMessage?: string;
emptySubMessage?: string;
- timelineUser?: { username: string; displayName: string; avatarUrl: string | null };
+ timelineUser?: {
+ username: string;
+ displayName: string;
+ avatarUrl: string | null;
+ relationship: {
+ blocking?: boolean;
+ blockedBy?: boolean;
+ muted?: boolean;
+ following?: boolean;
+ follower?: boolean;
+ };
+ };
blockedBy?: boolean;
allowQuoting?: boolean;
}>;
@@ -287,13 +311,13 @@ describe('TimelineFeedList', () => {
const tweetProps = renderedItem.props;
tweetProps.onPressAuthor?.('user-one');
- expect(mockRootNavigation.navigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ expect(mockRootNavigation.push).toHaveBeenCalledWith(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username: 'user-one' },
});
tweetProps.onPressMention?.('user-two');
- expect(mockRootNavigation.navigate).toHaveBeenCalledTimes(2);
+ expect(mockRootNavigation.push).toHaveBeenCalledTimes(2);
tweetProps.onPressTweet(' tweet-42 ');
expect(mockRootNavigation.navigate).toHaveBeenCalledWith(ROOT.TWEET, {
@@ -302,7 +326,7 @@ describe('TimelineFeedList', () => {
});
tweetProps.onPressTweet(' ');
- expect(mockRootNavigation.navigate).toHaveBeenCalledTimes(3);
+ expect(mockRootNavigation.navigate).toHaveBeenCalledTimes(1);
});
it('delegates tweet detail navigation to custom handler when provided', () => {
@@ -475,6 +499,7 @@ describe('TimelineFeedList', () => {
username: 'timeline-user',
displayName: 'Timeline User',
avatarUrl: 'https://example.com/avatar.jpg',
+ relationship: {},
};
renderComponent({ data: { pages: [{ data: [repostTweet] }] } }, { timelineUser });
@@ -484,6 +509,7 @@ describe('TimelineFeedList', () => {
username: 'timeline-user',
displayName: 'Timeline User',
avatarUrl: 'https://example.com/avatar.jpg',
+ relationship: {},
});
});
diff --git a/src/__tests__/components/ui/TrendCard.test.tsx b/src/__tests__/components/ui/TrendCard.test.tsx
index d9b105d0b..f033c3302 100644
--- a/src/__tests__/components/ui/TrendCard.test.tsx
+++ b/src/__tests__/components/ui/TrendCard.test.tsx
@@ -15,48 +15,34 @@ describe('TrendCard', () => {
const baseTrend = {
hashtag: 'react',
- tweetCount: 123,
+ tweetsCount: 123,
category: 'technology',
};
it('renders label, hashtag and count when showRank is false', () => {
const { getByText } = render( );
- expect(getByText('Trending in Technology')).toBeTruthy();
- expect(getByText('#react')).toBeTruthy();
+ expect(getByText('Technology · Trending')).toBeTruthy();
+ expect(getByText('react')).toBeTruthy();
expect(getByText('123 posts')).toBeTruthy();
});
it('renders label with rank when showRank is true', () => {
const { getByText } = render( );
- expect(getByText('2 · Trending in Technology')).toBeTruthy();
+ expect(getByText('2 · Technology · Trending')).toBeTruthy();
});
it('formats large counts into K and M suffixes', () => {
const { getByText, rerender } = render(
-
+
);
expect(getByText('1.5K posts')).toBeTruthy();
- rerender( );
+ rerender( );
expect(getByText('2.5M posts')).toBeTruthy();
});
- it('calls push with normalized hashtag when pressed', () => {
- const { getByText } = render( );
-
- const hashtagNode = getByText('#react');
-
- fireEvent.press(hashtagNode);
-
- expect(push).toHaveBeenCalledTimes(1);
- expect(push).toHaveBeenCalledWith(ROOT.SEARCH, {
- initialQuery: '#react',
- initialTab: 'top',
- });
- });
-
- it('normalizes an already prefixed hashtag and still calls push correctly', () => {
+ it('navigates to search results when clicked', () => {
const trendWithHash = { ...baseTrend, hashtag: '#typescript' };
const { getByText } = render( );
diff --git a/src/__tests__/components/ui/Tweet.test.tsx b/src/__tests__/components/ui/Tweet.test.tsx
index 7af63e72d..e59d98a37 100644
--- a/src/__tests__/components/ui/Tweet.test.tsx
+++ b/src/__tests__/components/ui/Tweet.test.tsx
@@ -11,7 +11,7 @@ import { useUserStore } from '@/stores/userStore';
import type { Tweet as TweetType } from '@/types/tweet';
-const { deleteTweet } = tweetsService;
+const { deleteTweet, getTweetSummary } = tweetsService;
const mockFollowMutation = {
mutate: jest.fn(),
@@ -46,10 +46,6 @@ jest.mock('@/hooks/profile/useBlockMutation', () => ({
useBlockMutation: jest.fn(() => mockBlockMutation),
}));
-jest.mock('@/hooks/profile/useBlockMutation', () => ({
- useBlockMutation: jest.fn(() => mockBlockMutation),
-}));
-
jest.mock('@/hooks/profile/useMuteMutation', () => ({
useMuteMutation: jest.fn(() => mockMuteMutation),
}));
@@ -80,6 +76,7 @@ jest.mock('@/services/tweets', () => ({
retweetTweet: jest.fn().mockResolvedValue({}),
unretweetTweet: jest.fn().mockResolvedValue({}),
deleteTweet: jest.fn(),
+ getTweetSummary: jest.fn(),
}));
jest.mock('@expo/vector-icons/FontAwesome6', () => 'FontAwesome6');
@@ -87,7 +84,18 @@ jest.mock('@expo/vector-icons/MaterialCommunityIcons', () => 'MaterialCommunityI
const baseTweet: TweetType = {
id: 't1',
- author: { username: 'ahmed', displayName: 'Ahmed', avatarUrl: null },
+ author: {
+ username: 'ahmed',
+ displayName: 'Ahmed',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
content: 'Hello @testing check #Raven',
createdAt: new Date().toISOString(),
replyCount: 0,
@@ -105,25 +113,28 @@ const baseTweet: TweetType = {
quotedTweet: null,
};
-const mockQueryClient = new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- mutations: { retry: false },
- },
-});
-
-const addProviders = (ui: React.ReactElement) => (
-
-
- {ui}
-
-
-);
+let testQueryClient: QueryClient;
+
+const addProviders = (ui: React.ReactElement) => {
+ testQueryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ return (
+
+
+ {ui}
+
+
+ );
+};
const renderWithProv = (ui: React.ReactElement) => render(addProviders(ui));
@@ -148,7 +159,9 @@ describe('Tweet component', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
- mockQueryClient.clear();
+ if (testQueryClient) {
+ testQueryClient.clear();
+ }
act(() => {
useUserStore.getState().resetUser();
@@ -172,7 +185,18 @@ describe('Tweet component', () => {
const tweetWithReposter: TweetType = {
...baseTweet,
isRepost: true,
- repostedBy: { username: 'sara', displayName: 'Sara', avatarUrl: null },
+ repostedBy: {
+ username: 'sara',
+ displayName: 'Sara',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
};
const { getByText } = renderWithProv( );
@@ -180,10 +204,10 @@ describe('Tweet component', () => {
getByText('Sara reposted');
});
- it('optimistically toggles like count', () => {
+ it('optimistically toggles like count', async () => {
const { getByTestId, getByText } = renderWithProv( );
fireEvent.press(getByTestId('like-button'));
- getByText('4');
+ await waitFor(() => getByText('4'));
});
it('rolls back like on failure', async () => {
@@ -250,8 +274,9 @@ describe('Tweet component', () => {
await waitFor(() => {
expect(tweetsService.unretweetTweet).toHaveBeenCalledWith(retweetedTweet.id);
- getByText('4');
});
+
+ await waitFor(() => getByText('4'));
});
it('shares the tweet link when share button is pressed', async () => {
@@ -375,7 +400,17 @@ describe('Tweet component', () => {
it('toggles follow state from the media viewer', () => {
const tweetWithVideo = {
...baseTweet,
- author: { ...baseTweet.author, isFollowing: false, isFollower: false },
+ author: {
+ ...baseTweet.author,
+ relationship: {
+ following: false,
+ follower: undefined,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
+ isFollower: false,
+ },
media: [
{
type: 'VIDEO' as const,
@@ -409,7 +444,17 @@ describe('Tweet component', () => {
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
const tweetWithVideo = {
...baseTweet,
- author: { ...baseTweet.author, isFollowing: false, isFollower: false },
+ author: {
+ ...baseTweet.author,
+ relationship: {
+ following: false,
+ follower: undefined,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
+ isFollower: false,
+ },
media: [
{
type: 'VIDEO' as const,
@@ -442,7 +487,17 @@ describe('Tweet component', () => {
mockFollowMutation.isPending = true;
const tweetWithVideo = {
...baseTweet,
- author: { ...baseTweet.author, isFollowing: false, isFollower: false },
+ author: {
+ ...baseTweet.author,
+ relationship: {
+ following: false,
+ follower: undefined,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
+ isFollower: false,
+ },
media: [
{
type: 'VIDEO' as const,
@@ -534,7 +589,18 @@ describe('Tweet component', () => {
it('renders quoted tweets in detailed mode', () => {
const quotedTweet: NonNullable = {
id: 'qt-1',
- author: { username: 'quoted', displayName: 'Quoted Author', avatarUrl: null },
+ author: {
+ username: 'quoted',
+ displayName: 'Quoted Author',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
content: 'Quoted tweet',
createdAt: new Date().toISOString(),
replyCount: 1,
@@ -604,27 +670,6 @@ describe('Tweet component', () => {
getByText(pattern);
});
- it('syncs like and retweet state when props change', () => {
- const { getByTestId, getByText, rerender } = renderWithProv( );
-
- fireEvent.press(getByTestId('like-button'));
- getByText('4');
-
- const updated: TweetType = {
- ...baseTweet,
- likeCount: 10,
- isLiked: true,
- retweetCount: 5,
- isRetweeted: true,
- };
-
- rerender(addProviders( ));
-
- getByText('10');
- fireEvent.press(getByTestId('like-button'));
- getByText('9');
- });
-
it('calls onQuote callback when quote option is selected', () => {
const onQuote = jest.fn();
const { getByTestId } = renderWithProv( );
@@ -664,7 +709,18 @@ describe('Tweet component', () => {
it('renders quoted tweet when quotedTweet is present', () => {
const quotedTweet: TweetType = {
id: 't2',
- author: { username: 'quoted_user', displayName: 'Quoted User', avatarUrl: null },
+ author: {
+ username: 'quoted_user',
+ displayName: 'Quoted User',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
content: 'This is a quoted tweet',
createdAt: new Date().toISOString(),
replyCount: 0,
@@ -695,7 +751,18 @@ describe('Tweet component', () => {
const onPress = jest.fn();
const quotedTweet: TweetType = {
id: 't2',
- author: { username: 'quoted_user', displayName: 'Quoted User', avatarUrl: null },
+ author: {
+ username: 'quoted_user',
+ displayName: 'Quoted User',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
content: 'This is a quoted tweet',
createdAt: new Date().toISOString(),
replyCount: 0,
@@ -726,7 +793,18 @@ describe('Tweet component', () => {
it('renders quoted tweet with media', () => {
const quotedTweet: TweetType = {
id: 't2',
- author: { username: 'quoted_user', displayName: 'Quoted User', avatarUrl: null },
+ author: {
+ username: 'quoted_user',
+ displayName: 'Quoted User',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
content: 'Tweet with image',
createdAt: new Date().toISOString(),
replyCount: 0,
@@ -763,7 +841,18 @@ describe('Tweet component', () => {
it('renders quoted tweet with media in detailed mode and opens viewer when pressed', () => {
const quotedTweet: NonNullable = {
id: 'qt-1',
- author: { username: 'quoted', displayName: 'Quoted Author', avatarUrl: null },
+ author: {
+ username: 'quoted',
+ displayName: 'Quoted Author',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
content: 'Quoted tweet with media',
createdAt: new Date().toISOString(),
replyCount: 1,
@@ -800,7 +889,18 @@ describe('Tweet component', () => {
const onPressAuthor = jest.fn();
const quotedTweet: NonNullable = {
id: 'qt-1',
- author: { username: 'quoted_author', displayName: 'Quoted Author', avatarUrl: null },
+ author: {
+ username: 'quoted_author',
+ displayName: 'Quoted Author',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
content: 'Quoted tweet content',
createdAt: new Date().toISOString(),
replyCount: 0,
@@ -1106,7 +1206,9 @@ describe('media viewer interactions', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
- mockQueryClient.clear();
+ if (testQueryClient) {
+ testQueryClient.clear();
+ }
act(() => {
useUserStore.getState().resetUser();
@@ -1136,6 +1238,11 @@ describe('media viewer interactions', () => {
const retweetButtons = getAllByTestId('retweet-button');
fireEvent.press(retweetButtons[retweetButtons.length - 1]);
+ // After pressing retweet button, the menu should open
+ // Now click the repost option
+ await waitFor(() => getByTestId('repost-option'));
+ fireEvent.press(getByTestId('repost-option'));
+
await waitFor(() => {
expect(tweetsService.retweetTweet).toHaveBeenCalledWith(tweetWithVideo.id);
});
@@ -1265,7 +1372,16 @@ describe('media viewer interactions', () => {
const onPressAuthor = jest.fn();
const tweetWithVideo: TweetType = {
...baseTweet,
- author: { ...baseTweet.author, isFollowing: false, isFollower: false },
+ author: {
+ ...baseTweet.author,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
media: [
{
type: 'VIDEO',
@@ -1325,7 +1441,18 @@ describe('media viewer interactions', () => {
const tweetWithEmptyReposter: TweetType = {
...baseTweet,
isRepost: true,
- repostedBy: { username: '', displayName: '', avatarUrl: null },
+ repostedBy: {
+ username: '',
+ displayName: '',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
};
const { getByText } = renderWithProv( );
@@ -1336,7 +1463,18 @@ describe('media viewer interactions', () => {
const tweetWithUsernameReposter: TweetType = {
...baseTweet,
isRepost: true,
- repostedBy: { username: 'john', displayName: '', avatarUrl: null },
+ repostedBy: {
+ username: 'john',
+ displayName: '',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
};
const { getByText } = renderWithProv( );
@@ -1347,7 +1485,18 @@ describe('media viewer interactions', () => {
const onPressAuthor = jest.fn();
const quotedTweet: NonNullable = {
id: 'qt-list-1',
- author: { username: 'quoted_user', displayName: 'Quoted User', avatarUrl: null },
+ author: {
+ username: 'quoted_user',
+ displayName: 'Quoted User',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
content: 'Quoted content',
createdAt: new Date().toISOString(),
replyCount: 0,
@@ -1377,7 +1526,18 @@ describe('media viewer interactions', () => {
const onPressMedia = jest.fn();
const quotedTweet: NonNullable = {
id: 'qt-media-custom-1',
- author: { username: 'quoted', displayName: 'Quoted Author', avatarUrl: null },
+ author: {
+ username: 'quoted',
+ displayName: 'Quoted Author',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
content: 'Quoted with media',
createdAt: new Date().toISOString(),
replyCount: 0,
@@ -1424,8 +1584,9 @@ describe('media viewer interactions', () => {
await waitFor(() => {
expect(tweetsService.unlikeTweet).toHaveBeenCalledWith(likedTweet.id);
- getByText('4');
});
+
+ await waitFor(() => getByText('4'));
});
it('rolls back unlike on failure', async () => {
@@ -1465,7 +1626,9 @@ describe('follow button visibility in video viewer', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
- mockQueryClient.clear();
+ if (testQueryClient) {
+ testQueryClient.clear();
+ }
act(() => {
useUserStore.getState().resetUser();
@@ -1504,3 +1667,349 @@ describe('follow button visibility in video viewer', () => {
expect(queryByTestId('viewer-follow-button')).toBeNull();
});
});
+
+describe('AI summary functionality', () => {
+ beforeEach(() => {
+ act(() => {
+ const storeState = useUserStore.getState();
+ storeState.resetUser();
+ useUserStore.setState({
+ user: {
+ ...useUserStore.getState().user,
+ username: 'viewer-user',
+ displayName: 'Viewer User',
+ },
+ });
+ });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ jest.clearAllMocks();
+ if (testQueryClient) {
+ testQueryClient.clear();
+ }
+
+ act(() => {
+ useUserStore.getState().resetUser();
+ });
+ });
+
+ it('fetches and displays AI summary when grok button is pressed in detailed view', async () => {
+ (getTweetSummary as jest.Mock).mockResolvedValueOnce({
+ success: true,
+ data: { summary: 'This is an AI generated summary of the tweet.' },
+ });
+
+ const { getByTestId, getByText } = renderWithProv( );
+
+ fireEvent.press(getByTestId('grok-summary-button'));
+
+ await waitFor(() => {
+ expect(getTweetSummary).toHaveBeenCalledWith(baseTweet.id);
+ });
+
+ await waitFor(() => {
+ getByText('This is an AI generated summary of the tweet.');
+ });
+ });
+
+ it('shows error state when AI summary fails', async () => {
+ (getTweetSummary as jest.Mock).mockResolvedValueOnce({
+ success: false,
+ data: null,
+ });
+
+ const { getByTestId, getByText } = renderWithProv( );
+
+ fireEvent.press(getByTestId('grok-summary-button'));
+
+ await waitFor(() => {
+ getByText('Failed to generate summary');
+ });
+ });
+
+ it('shows error state when getTweetSummary throws', async () => {
+ (getTweetSummary as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
+
+ const { getByTestId, getByText } = renderWithProv( );
+
+ fireEvent.press(getByTestId('grok-summary-button'));
+
+ await waitFor(() => {
+ getByText('Failed to generate summary');
+ });
+ });
+
+ it('does not show grok button when tweet has empty content', () => {
+ const emptyContentTweet: TweetType = {
+ ...baseTweet,
+ content: ' ',
+ };
+
+ const { queryByTestId } = renderWithProv( );
+
+ expect(queryByTestId('grok-summary-button')).toBeNull();
+ });
+});
+
+describe('Reply-to-tweet and deleted quoted tweet', () => {
+ beforeEach(() => {
+ act(() => {
+ const storeState = useUserStore.getState();
+ storeState.resetUser();
+ useUserStore.setState({
+ user: {
+ ...useUserStore.getState().user,
+ username: 'viewer-user',
+ displayName: 'Viewer User',
+ },
+ });
+ });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ jest.clearAllMocks();
+ if (testQueryClient) {
+ testQueryClient.clear();
+ }
+
+ act(() => {
+ useUserStore.getState().resetUser();
+ });
+ });
+
+ it('displays reply-to info in list view and navigates to author on press', () => {
+ const onPressAuthor = jest.fn();
+ const tweetWithReply: TweetType = {
+ ...baseTweet,
+ replyToTweet: {
+ id: 'reply-parent',
+ author: {
+ username: 'original_author',
+ displayName: 'Original Author',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
+ content: 'Original tweet content',
+ createdAt: new Date().toISOString(),
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 0,
+ isLiked: false,
+ isRetweeted: false,
+ entities: { mentions: null, hashtags: null },
+ media: null,
+ },
+ };
+
+ const { getByText } = renderWithProv(
+
+ );
+
+ getByText('Replying to');
+ const replyAuthor = getByText('@original_author');
+ fireEvent.press(replyAuthor);
+
+ expect(onPressAuthor).toHaveBeenCalledWith('original_author');
+ });
+
+ it('renders deleted quoted tweet message in list view', () => {
+ const tweetWithDeletedQuote: TweetType = {
+ ...baseTweet,
+ quotedTweet: { isDeleted: true },
+ };
+
+ const { getByText } = renderWithProv( );
+
+ getByText('This tweet was deleted.');
+ });
+
+ it('renders deleted quoted tweet message in detailed view', () => {
+ const tweetWithDeletedQuote: TweetType = {
+ ...baseTweet,
+ quotedTweet: { isDeleted: true },
+ };
+
+ const { getByText } = renderWithProv( );
+
+ getByText('This tweet was deleted.');
+ });
+});
+
+describe('Delete tweet error handling', () => {
+ beforeEach(() => {
+ act(() => {
+ const storeState = useUserStore.getState();
+ storeState.resetUser();
+ useUserStore.setState({
+ user: {
+ ...useUserStore.getState().user,
+ username: 'viewer-user',
+ displayName: 'Viewer User',
+ },
+ });
+ });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ jest.clearAllMocks();
+ if (testQueryClient) {
+ testQueryClient.clear();
+ }
+
+ act(() => {
+ useUserStore.getState().resetUser();
+ });
+ });
+
+ it('shows alert when delete tweet fails', async () => {
+ const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
+ (deleteTweet as jest.Mock).mockRejectedValueOnce(new Error('Delete failed'));
+
+ const { getByTestId, getByText } = renderWithProv(
+
+ );
+
+ const drawerButton = getByTestId('tweet-drawer');
+ fireEvent.press(drawerButton);
+
+ const deleteButton = getByText('Delete post');
+ act(() => {
+ fireEvent.press(deleteButton);
+ });
+
+ const confirmButton = getByTestId('dialog-confirm-button');
+ act(() => {
+ fireEvent.press(confirmButton);
+ });
+
+ await waitFor(() => {
+ expect(alertSpy).toHaveBeenCalledWith('Unable to delete tweet', 'Delete failed');
+ });
+ });
+});
+
+describe('Quoted tweet detailed view interactions', () => {
+ beforeEach(() => {
+ act(() => {
+ const storeState = useUserStore.getState();
+ storeState.resetUser();
+ useUserStore.setState({
+ user: {
+ ...useUserStore.getState().user,
+ username: 'viewer-user',
+ displayName: 'Viewer User',
+ },
+ });
+ });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ jest.clearAllMocks();
+ if (testQueryClient) {
+ testQueryClient.clear();
+ }
+
+ act(() => {
+ useUserStore.getState().resetUser();
+ });
+ });
+
+ it('navigates to quoted tweet when pressed in detailed view', () => {
+ const onPress = jest.fn();
+ const quotedTweet: NonNullable = {
+ id: 'qt-detailed-press',
+ author: {
+ username: 'quoted_author',
+ displayName: 'Quoted Author',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
+ content: 'Press this quoted tweet',
+ createdAt: new Date().toISOString(),
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 0,
+ isLiked: false,
+ isRetweeted: false,
+ entities: { mentions: null, hashtags: null },
+ media: null,
+ };
+
+ const richTweet: TweetType = {
+ ...baseTweet,
+ quotedTweet,
+ };
+
+ const { getByText } = renderWithProv( );
+
+ fireEvent.press(getByText('Press this quoted tweet'));
+ expect(onPress).toHaveBeenCalledWith('qt-detailed-press');
+ });
+
+ it('opens viewer for quoted tweet media in list view without custom handler', () => {
+ const quotedTweet: NonNullable = {
+ id: 'qt-media-list',
+ author: {
+ username: 'quoted',
+ displayName: 'Quoted',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
+ content: 'Quoted media tweet',
+ createdAt: new Date().toISOString(),
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 0,
+ isLiked: false,
+ isRetweeted: false,
+ entities: { mentions: null, hashtags: null },
+ media: [
+ {
+ type: 'IMAGE',
+ url: 'https://example.com/quoted-media.jpg',
+ altText: 'Quoted media',
+ width: 800,
+ height: 600,
+ },
+ ],
+ };
+
+ const tweetWithQuotedMedia: TweetType = {
+ ...baseTweet,
+ quotedTweet,
+ };
+
+ const { getByTestId } = renderWithProv( );
+
+ fireEvent.press(getByTestId('tweet-media-0'));
+ getByTestId('media-viewer');
+ });
+});
diff --git a/src/__tests__/components/ui/TweetComposerContent.test.tsx b/src/__tests__/components/ui/TweetComposerContent.test.tsx
new file mode 100644
index 000000000..644df4f4a
--- /dev/null
+++ b/src/__tests__/components/ui/TweetComposerContent.test.tsx
@@ -0,0 +1,325 @@
+import { TextInputProps, TextProps, ViewProps } from 'react-native';
+
+import { fireEvent, render, screen } from '@testing-library/react-native';
+
+import TweetComposerContent from '@/components/ui/TweetComposerContent';
+import { Asset } from '@/types/media';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'dark' }),
+}));
+
+jest.mock('@/components/ui/AppText', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { Text } = require('react-native');
+ const MockAppText = (props: TextProps) => ;
+ return MockAppText;
+});
+
+jest.mock('@/components/ui/Avatar', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { View } = require('react-native');
+ const MockAvatar = (props: ViewProps) => ;
+ return MockAvatar;
+});
+
+jest.mock('@/components/ui/HighlightableTextInput', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { TextInput } = require('react-native');
+ const MockHighlightableTextInput = (props: TextInputProps) => (
+
+ );
+ return MockHighlightableTextInput;
+});
+
+jest.mock('@/components/ui/SelectedMediaRow', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { View, Button } = require('react-native');
+ const MockSelectedMediaRow = ({
+ onRemove,
+ onOpenPreview,
+ selectedAssets,
+ }: {
+ onRemove: (asset: Asset) => void;
+ onOpenPreview: (asset: Asset) => void;
+ selectedAssets: Asset[];
+ }) => (
+
+ {selectedAssets.map((asset: Asset) => (
+
+ onRemove(asset)}
+ testID={`remove-asset-${asset.id}`}
+ />
+ onOpenPreview(asset)}
+ testID={`preview-asset-${asset.id}`}
+ />
+
+ ))}
+
+ );
+ return MockSelectedMediaRow;
+});
+
+jest.mock('@/components/ui/TweetContent', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { Text } = require('react-native');
+ const MockTweetContent = (props: { text: string }) => (
+ {props.text}
+ );
+ return MockTweetContent;
+});
+
+jest.mock('@/components/ui/TweetMediaGrid', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { View } = require('react-native');
+ const MockTweetMediaGrid = () => ;
+ return MockTweetMediaGrid;
+});
+
+describe('TweetComposerContent', () => {
+ const mockSetTweetText = jest.fn();
+ const mockOnOpenPreview = jest.fn();
+ const mockOnRemove = jest.fn();
+ const mockOnPressAuthor = jest.fn();
+
+ const defaultProps = {
+ tweetText: '',
+ setTweetText: mockSetTweetText,
+ currentUser: {
+ name: 'Test User',
+ handle: 'testuser',
+ displayName: 'Test User',
+ iconUrl: 'https://example.com/avatar.png',
+ },
+ selectedAssets: [],
+ onOpenPreview: mockOnOpenPreview,
+ onRemove: mockOnRemove,
+ characterLimit: 280,
+ onPressAuthor: mockOnPressAuthor,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly in default mode (new tweet)', () => {
+ render( );
+
+ // Check for input
+ const input = screen.getByTestId('highlightable-text-input');
+ expect(input).toBeTruthy();
+ expect(input.props.placeholder).toBe('Share your thoughts...');
+
+ // Check for Avatar
+ expect(screen.getByTestId('avatar')).toBeTruthy();
+
+ // Check no reply UI
+ expect(screen.queryByText('Replying to')).toBeNull();
+ });
+
+ it('renders correctly in reply mode', () => {
+ const replyProps = {
+ ...defaultProps,
+ replyToTweet: {
+ content: 'Original tweet content',
+ author: {
+ username: 'originalparams',
+ displayName: 'Original Author',
+ avatarUrl: 'https://example.com/orig.png',
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
+ },
+ createdAt: '2023-01-01T00:00:00Z',
+ media: [],
+ entities: null,
+ },
+ replyToAuthor: {
+ username: 'originalparams',
+ displayName: 'Original Author',
+ avatarUrl: 'https://example.com/orig.png',
+ },
+ };
+
+ render( );
+
+ const input = screen.getByTestId('highlightable-text-input');
+ expect(input.props.placeholder).toBe('Post your reply');
+
+ expect(screen.getByTestId('tweet-content')).toBeTruthy();
+ expect(screen.getByText('Original tweet content')).toBeTruthy();
+
+ expect(screen.getByText('Replying to')).toBeTruthy();
+ expect(screen.getAllByText(/originalparams/).length).toBeGreaterThan(0);
+ expect(screen.getByTestId('replying-to-username')).toBeTruthy();
+ });
+
+ it('handles text input changes', () => {
+ render( );
+
+ const input = screen.getByTestId('highlightable-text-input');
+ fireEvent.changeText(input, 'Hello World');
+
+ expect(mockSetTweetText).toHaveBeenCalledWith('Hello World');
+ });
+
+ it('renders selected assets and handles interactions', () => {
+ const assets: Asset[] = [
+ { id: '1', uri: 'image1.jpg', mediaType: 'photo' },
+ { id: '2', uri: 'video1.mp4', mediaType: 'video' },
+ ];
+ render( );
+
+ expect(screen.getByTestId('selected-media-row')).toBeTruthy();
+
+ const removeBtn = screen.getByTestId('remove-asset-1');
+ fireEvent.press(removeBtn);
+ expect(mockOnRemove).toHaveBeenCalledWith(assets[0]);
+
+ const previewBtn = screen.getByTestId('preview-asset-2');
+ fireEvent.press(previewBtn);
+ expect(mockOnOpenPreview).toHaveBeenCalledWith(assets[1]);
+ });
+
+ it('handles responding to author press', () => {
+ const replyProps = {
+ ...defaultProps,
+ replyToTweet: {
+ content: 'Original tweet content',
+ author: {
+ username: 'originalparams',
+ displayName: 'Original Author',
+ avatarUrl: 'https://example.com/orig.png',
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
+ },
+ createdAt: '2023-01-01T00:00:00Z',
+ media: [],
+ entities: null,
+ },
+ replyToAuthor: {
+ username: 'originalparams',
+ displayName: 'Original Author',
+ avatarUrl: 'https://example.com/orig.png',
+ },
+ };
+
+ render( );
+
+ const replyBtn = screen.getByTestId('replying-to-username');
+ fireEvent.press(replyBtn);
+
+ expect(mockOnPressAuthor).toHaveBeenCalledWith('originalparams');
+ });
+
+ it('displays original tweet media if present', () => {
+ const replyProps = {
+ ...defaultProps,
+ replyToTweet: {
+ content: 'Original tweet content',
+ author: {
+ username: 'originalparams',
+ displayName: 'Original Author',
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
+ },
+ createdAt: '2023-01-01T00:00:00Z',
+ media: [
+ {
+ type: 'IMAGE' as const,
+ url: 'http://img.com',
+ altText: 'Alt',
+ width: 100,
+ height: 100,
+ },
+ ],
+ entities: null,
+ },
+ replyToAuthor: {
+ username: 'originalparams',
+ displayName: 'Original Author',
+ avatarUrl: 'https://example.com/orig.png',
+ },
+ };
+
+ render( );
+ expect(screen.getByTestId('tweet-media-grid')).toBeTruthy();
+ });
+
+ it('calculates line height on layout', () => {
+ const replyProps = {
+ ...defaultProps,
+ replyToTweet: {
+ content: 'Original',
+ author: {
+ username: 'orig',
+ displayName: 'Orig',
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
+ },
+ createdAt: '2023-01-01T00:00:00Z',
+ media: [],
+ entities: null,
+ },
+ replyToAuthor: {
+ username: 'orig',
+ displayName: 'Orig',
+ avatarUrl: 'https://example.com/orig.png',
+ },
+ };
+
+ render( );
+
+ const connectorView = screen.getByTestId('thread-connector-view');
+
+ const mockMeasure = jest.fn((callback) => {
+ // x, y, width, height, pageX, pageY
+ callback(0, 0, 0, 0, 0, 200);
+ });
+
+ const event = {
+ nativeEvent: {
+ layout: { x: 0, y: 0, width: 100, height: 100 },
+ target: {
+ measure: mockMeasure,
+ },
+ },
+ target: {
+ measure: mockMeasure,
+ },
+ };
+
+ fireEvent(connectorView, 'layout', event);
+
+ expect(mockMeasure).toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/components/ui/TweetContent.test.tsx b/src/__tests__/components/ui/TweetContent.test.tsx
index 9a4d8c91e..7c77c8798 100644
--- a/src/__tests__/components/ui/TweetContent.test.tsx
+++ b/src/__tests__/components/ui/TweetContent.test.tsx
@@ -157,4 +157,34 @@ describe('TweetContent', () => {
expect(openSpy).toHaveBeenCalledWith('https://raven.cmp27.space');
expect(onPressMention).toHaveBeenCalledWith('ahmed');
});
+
+ it('handles case-insensitive hashtag matching', () => {
+ const onPressHashtag = jest.fn();
+
+ const entities: TweetEntities = {
+ mentions: [],
+ hashtags: [
+ { hashtag: 'breakingnews', startPosition: 98 },
+ { hashtag: 'cybersecurity', startPosition: 178 },
+ ],
+ };
+
+ const text =
+ 'BREAKING: Major tech company confirms a large-scale data breach impacting user accounts worldwide #BreakingNews. Users are advised to update passwords as investigations continue #CyberSecurity #';
+
+ const { getByText } = render(
+
+
+
+ );
+
+ const breakingNewsHashtag = getByText('#BreakingNews');
+ const cyberSecurityHashtag = getByText('#CyberSecurity');
+
+ breakingNewsHashtag.props.onPress();
+ expect(onPressHashtag).toHaveBeenCalledWith('BreakingNews');
+
+ cyberSecurityHashtag.props.onPress();
+ expect(onPressHashtag).toHaveBeenCalledWith('CyberSecurity');
+ });
});
diff --git a/src/__tests__/components/ui/TweetSectionList.test.tsx b/src/__tests__/components/ui/TweetSectionList.test.tsx
index 242c8c890..610d95d64 100644
--- a/src/__tests__/components/ui/TweetSectionList.test.tsx
+++ b/src/__tests__/components/ui/TweetSectionList.test.tsx
@@ -30,6 +30,7 @@ const mockQueryClient = new QueryClient({
describe('TweetSectionList', () => {
// shared navigate mock used by the spy
const navigateMock = jest.fn();
+ const pushMock = jest.fn();
const makeSection = (category: string, tweets: Tweet[]) => ({
category,
@@ -45,6 +46,13 @@ describe('TweetSectionList', () => {
username: 'username',
displayName: 'username',
avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
createdAt: '2023-01-01',
replyCount: 0,
@@ -64,11 +72,12 @@ describe('TweetSectionList', () => {
beforeEach(() => {
// clear the mock call history
navigateMock.mockClear();
+ pushMock.mockClear();
// Spy on the hook and ensure the component receives our navigate mock.
jest
.spyOn(useRootNavigationModule, 'useRootNavigation')
- .mockReturnValue({ navigate: navigateMock } as unknown as ReturnType<
+ .mockReturnValue({ navigate: navigateMock, push: pushMock } as unknown as ReturnType<
typeof useRootNavigationModule.useRootNavigation
>);
});
@@ -116,8 +125,8 @@ describe('TweetSectionList', () => {
fireEvent.press(avatarButton);
- expect(navigateMock).toHaveBeenCalledTimes(1);
- expect(navigateMock).toHaveBeenCalledWith(ROOT.PROFILE, {
+ expect(pushMock).toHaveBeenCalledTimes(1);
+ expect(pushMock).toHaveBeenCalledWith(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username: 'username' },
});
diff --git a/src/__tests__/components/utils/Captcha.test.tsx b/src/__tests__/components/utils/Captcha.test.tsx
index 32c662127..2ab0502a8 100644
--- a/src/__tests__/components/utils/Captcha.test.tsx
+++ b/src/__tests__/components/utils/Captcha.test.tsx
@@ -153,4 +153,65 @@ describe('Captcha', () => {
expect(onEnd).toHaveBeenCalled();
expect(onErr).toHaveBeenCalled();
});
+
+ it('uses provided languageCode', () => {
+ render( );
+ const props = (WebView as jest.Mock).mock.calls[0][0];
+ expect(props.source.html).toContain('hl=fr');
+ });
+
+ it('handles optional onHeightChange without crashing', async () => {
+ render( );
+
+ // Should not throw when recieving messages
+ await act(async () => {
+ triggerMessage('expanded');
+ triggerMessage('collapsed');
+ });
+ // If we reached here without error, it passed.
+ expect(true).toBe(true);
+ });
+
+ it('injects current theme background color', () => {
+ render( );
+ const props = (WebView as jest.Mock).mock.calls[0][0];
+ expect(props.source.html).toContain('background-color: #ffffff');
+ });
+
+ it('injected script patches postMessage correctly', () => {
+ render( );
+ const props = (WebView as jest.Mock).mock.calls[0][0];
+ const script = props.injectedJavaScript;
+
+ const originalPostMessage = jest.fn();
+
+ // Check if JSDOM window exists
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const globalWin = global as any;
+ const originalRNWV = globalWin.ReactNativeWebView;
+
+ try {
+ if (!globalWin.ReactNativeWebView) {
+ globalWin.ReactNativeWebView = { postMessage: originalPostMessage };
+ }
+
+ const initialPostMessage = globalWin.ReactNativeWebView.postMessage;
+
+ eval(script);
+
+ const patchedPostMessage = globalWin.ReactNativeWebView.postMessage;
+ expect(patchedPostMessage).not.toBe(initialPostMessage);
+
+ patchedPostMessage('test', '*', undefined);
+ expect(initialPostMessage).toHaveBeenCalledWith('test', '*', undefined);
+
+ expect(patchedPostMessage.toString()).toContain('postMessage');
+ } finally {
+ if (originalRNWV) {
+ globalWin.ReactNativeWebView = originalRNWV;
+ } else {
+ delete globalWin.ReactNativeWebView;
+ }
+ }
+ });
});
diff --git a/src/__tests__/hooks/explore/useEntertainmentTrends.test.tsx b/src/__tests__/hooks/explore/useEntertainmentTrends.test.tsx
new file mode 100644
index 000000000..f5829dff9
--- /dev/null
+++ b/src/__tests__/hooks/explore/useEntertainmentTrends.test.tsx
@@ -0,0 +1,179 @@
+import React from 'react';
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react-native';
+
+import { useEntertainmentTrends } from '@/hooks/explore/useEntertainmentTrends';
+import * as exploreService from '@/services/explore';
+import { useUserStore } from '@/stores/userStore';
+
+import type { Trend } from '@/types/explore';
+import type { UserProfile } from '@/types/user';
+
+jest.mock('@/stores/userStore', () => ({
+ useUserStore: jest.fn(),
+}));
+
+jest.mock('@/services/explore', () => ({
+ getEntertainmentTrends: jest.fn(),
+}));
+
+const mockUseUserStore = useUserStore as jest.MockedFunction;
+const mockGetEntertainmentTrends = exploreService.getEntertainmentTrends as jest.MockedFunction<
+ typeof exploreService.getEntertainmentTrends
+>;
+
+type UserStoreStateShape = {
+ user: UserProfile;
+ isLoading: boolean;
+ error: string | null;
+ updateUser: (data: Partial) => void;
+ setUser: (data: UserProfile) => void;
+ getUsername: () => string;
+ setLoading: (loading: boolean) => void;
+ setError: (error: string | null) => void;
+ clearError: () => void;
+ resetUser: () => void;
+};
+
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+const createWrapper = (queryClient: QueryClient) => {
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ Wrapper.displayName = 'QueryClientWrapper';
+ return Wrapper;
+};
+
+const defaultUserProfile = (overrides?: Partial): UserProfile => ({
+ username: '',
+ displayName: '',
+ bio: null,
+ bioEntities: null,
+ avatarUrl: 'http://www.gravatar.com/avatar/?d=mp',
+ bannerUrl: null,
+ location: null,
+ websiteUrl: null,
+ birthDate: null,
+ joinedAt: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ followingCount: 0,
+ followersCount: 0,
+ mutualsCount: 0,
+ mutualUsers: [],
+ ...overrides,
+});
+
+const createUserStoreState = (overrides?: Partial): UserStoreStateShape => {
+ const user = overrides?.user ?? defaultUserProfile();
+ const state: UserStoreStateShape = {
+ user,
+ isLoading: false,
+ error: null,
+ updateUser: jest.fn(),
+ setUser: jest.fn(),
+ getUsername: () => user.username,
+ setLoading: jest.fn(),
+ setError: jest.fn(),
+ clearError: jest.fn(),
+ resetUser: jest.fn(),
+ ...overrides,
+ };
+
+ return state;
+};
+
+const setUserStoreState = (overrides?: Partial) => {
+ const state = createUserStoreState(overrides);
+ mockUseUserStore.mockImplementation(
+ (selector?: (s: UserStoreStateShape) => unknown, _equals?: unknown) =>
+ selector ? selector(state) : state
+ );
+ return state;
+};
+
+describe('useEntertainmentTrends', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return undefined when username is not set', async () => {
+ setUserStoreState({
+ user: defaultUserProfile({ username: '' }),
+ });
+
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ const { result } = renderHook(() => useEntertainmentTrends(), { wrapper });
+
+ expect(result.current.isPending).toBeTruthy();
+ expect(mockGetEntertainmentTrends).not.toHaveBeenCalled();
+ });
+
+ it('should call getEntertainmentTrends and return data when username is set', async () => {
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ setUserStoreState({
+ user: defaultUserProfile({ username: 'alice' }),
+ });
+
+ const sampleTrends: Trend[] = [
+ { hashtag: '#movies', tweetsCount: 8000, category: 'entertainment' },
+ { hashtag: '#music', tweetsCount: 6500, category: 'entertainment' },
+ ];
+
+ mockGetEntertainmentTrends.mockResolvedValue({
+ success: true,
+ data: sampleTrends,
+ });
+
+ const { result } = renderHook(() => useEntertainmentTrends(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBeTruthy();
+ });
+
+ expect(mockGetEntertainmentTrends).toHaveBeenCalledTimes(1);
+ expect(result.current.data).toEqual({
+ success: true,
+ data: sampleTrends,
+ });
+ });
+
+ it('should return error when getEntertainmentTrends fails', async () => {
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ setUserStoreState({
+ user: defaultUserProfile({ username: 'alice' }),
+ });
+
+ const errorMessage = 'Failed to fetch entertainment trends';
+ mockGetEntertainmentTrends.mockRejectedValue(new Error(errorMessage));
+
+ const { result } = renderHook(() => useEntertainmentTrends(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isError).toBeTruthy();
+ });
+
+ expect(result.current.error).toBeDefined();
+ expect(mockGetEntertainmentTrends).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/__tests__/hooks/explore/useForYou.test.tsx b/src/__tests__/hooks/explore/useForYou.test.tsx
index ec512171d..2a0a634fc 100644
--- a/src/__tests__/hooks/explore/useForYou.test.tsx
+++ b/src/__tests__/hooks/explore/useForYou.test.tsx
@@ -42,6 +42,13 @@ const mockCategories: Categories = {
username: 'techuser',
displayName: 'Tech User',
avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
createdAt: new Date().toISOString(),
likeCount: 10,
diff --git a/src/__tests__/hooks/explore/useNewsTrends.test.tsx b/src/__tests__/hooks/explore/useNewsTrends.test.tsx
new file mode 100644
index 000000000..cbfec59eb
--- /dev/null
+++ b/src/__tests__/hooks/explore/useNewsTrends.test.tsx
@@ -0,0 +1,179 @@
+import React from 'react';
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react-native';
+
+import { useNewsTrends } from '@/hooks/explore/useNewsTrends';
+import * as exploreService from '@/services/explore';
+import { useUserStore } from '@/stores/userStore';
+
+import type { Trend } from '@/types/explore';
+import type { UserProfile } from '@/types/user';
+
+jest.mock('@/stores/userStore', () => ({
+ useUserStore: jest.fn(),
+}));
+
+jest.mock('@/services/explore', () => ({
+ getNewsTrends: jest.fn(),
+}));
+
+const mockUseUserStore = useUserStore as jest.MockedFunction;
+const mockGetNewsTrends = exploreService.getNewsTrends as jest.MockedFunction<
+ typeof exploreService.getNewsTrends
+>;
+
+type UserStoreStateShape = {
+ user: UserProfile;
+ isLoading: boolean;
+ error: string | null;
+ updateUser: (data: Partial) => void;
+ setUser: (data: UserProfile) => void;
+ getUsername: () => string;
+ setLoading: (loading: boolean) => void;
+ setError: (error: string | null) => void;
+ clearError: () => void;
+ resetUser: () => void;
+};
+
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+const createWrapper = (queryClient: QueryClient) => {
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ Wrapper.displayName = 'QueryClientWrapper';
+ return Wrapper;
+};
+
+const defaultUserProfile = (overrides?: Partial): UserProfile => ({
+ username: '',
+ displayName: '',
+ bio: null,
+ bioEntities: null,
+ avatarUrl: 'http://www.gravatar.com/avatar/?d=mp',
+ bannerUrl: null,
+ location: null,
+ websiteUrl: null,
+ birthDate: null,
+ joinedAt: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ followingCount: 0,
+ followersCount: 0,
+ mutualsCount: 0,
+ mutualUsers: [],
+ ...overrides,
+});
+
+const createUserStoreState = (overrides?: Partial): UserStoreStateShape => {
+ const user = overrides?.user ?? defaultUserProfile();
+ const state: UserStoreStateShape = {
+ user,
+ isLoading: false,
+ error: null,
+ updateUser: jest.fn(),
+ setUser: jest.fn(),
+ getUsername: () => user.username,
+ setLoading: jest.fn(),
+ setError: jest.fn(),
+ clearError: jest.fn(),
+ resetUser: jest.fn(),
+ ...overrides,
+ };
+
+ return state;
+};
+
+const setUserStoreState = (overrides?: Partial) => {
+ const state = createUserStoreState(overrides);
+ mockUseUserStore.mockImplementation(
+ (selector?: (s: UserStoreStateShape) => unknown, _equals?: unknown) =>
+ selector ? selector(state) : state
+ );
+ return state;
+};
+
+describe('useNewsTrends', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return undefined when username is not set', async () => {
+ setUserStoreState({
+ user: defaultUserProfile({ username: '' }),
+ });
+
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ const { result } = renderHook(() => useNewsTrends(), { wrapper });
+
+ expect(result.current.isPending).toBeTruthy();
+ expect(mockGetNewsTrends).not.toHaveBeenCalled();
+ });
+
+ it('should call getNewsTrends and return data when username is set', async () => {
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ setUserStoreState({
+ user: defaultUserProfile({ username: 'alice' }),
+ });
+
+ const sampleTrends: Trend[] = [
+ { hashtag: '#news1', tweetsCount: 5000, category: 'news' },
+ { hashtag: '#news2', tweetsCount: 3500, category: 'news' },
+ ];
+
+ mockGetNewsTrends.mockResolvedValue({
+ success: true,
+ data: sampleTrends,
+ });
+
+ const { result } = renderHook(() => useNewsTrends(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBeTruthy();
+ });
+
+ expect(mockGetNewsTrends).toHaveBeenCalledTimes(1);
+ expect(result.current.data).toEqual({
+ success: true,
+ data: sampleTrends,
+ });
+ });
+
+ it('should return error when getNewsTrends fails', async () => {
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ setUserStoreState({
+ user: defaultUserProfile({ username: 'alice' }),
+ });
+
+ const errorMessage = 'Failed to fetch news trends';
+ mockGetNewsTrends.mockRejectedValue(new Error(errorMessage));
+
+ const { result } = renderHook(() => useNewsTrends(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isError).toBeTruthy();
+ });
+
+ expect(result.current.error).toBeDefined();
+ expect(mockGetNewsTrends).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/__tests__/hooks/explore/useSportsTrends.test.tsx b/src/__tests__/hooks/explore/useSportsTrends.test.tsx
new file mode 100644
index 000000000..0bd63c63a
--- /dev/null
+++ b/src/__tests__/hooks/explore/useSportsTrends.test.tsx
@@ -0,0 +1,179 @@
+import React from 'react';
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react-native';
+
+import { useSportsTrends } from '@/hooks/explore/useSportsTrends';
+import * as exploreService from '@/services/explore';
+import { useUserStore } from '@/stores/userStore';
+
+import type { Trend } from '@/types/explore';
+import type { UserProfile } from '@/types/user';
+
+jest.mock('@/stores/userStore', () => ({
+ useUserStore: jest.fn(),
+}));
+
+jest.mock('@/services/explore', () => ({
+ getSportsTrends: jest.fn(),
+}));
+
+const mockUseUserStore = useUserStore as jest.MockedFunction;
+const mockGetSportsTrends = exploreService.getSportsTrends as jest.MockedFunction<
+ typeof exploreService.getSportsTrends
+>;
+
+type UserStoreStateShape = {
+ user: UserProfile;
+ isLoading: boolean;
+ error: string | null;
+ updateUser: (data: Partial) => void;
+ setUser: (data: UserProfile) => void;
+ getUsername: () => string;
+ setLoading: (loading: boolean) => void;
+ setError: (error: string | null) => void;
+ clearError: () => void;
+ resetUser: () => void;
+};
+
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+const createWrapper = (queryClient: QueryClient) => {
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ Wrapper.displayName = 'QueryClientWrapper';
+ return Wrapper;
+};
+
+const defaultUserProfile = (overrides?: Partial): UserProfile => ({
+ username: '',
+ displayName: '',
+ bio: null,
+ bioEntities: null,
+ avatarUrl: 'http://www.gravatar.com/avatar/?d=mp',
+ bannerUrl: null,
+ location: null,
+ websiteUrl: null,
+ birthDate: null,
+ joinedAt: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ followingCount: 0,
+ followersCount: 0,
+ mutualsCount: 0,
+ mutualUsers: [],
+ ...overrides,
+});
+
+const createUserStoreState = (overrides?: Partial): UserStoreStateShape => {
+ const user = overrides?.user ?? defaultUserProfile();
+ const state: UserStoreStateShape = {
+ user,
+ isLoading: false,
+ error: null,
+ updateUser: jest.fn(),
+ setUser: jest.fn(),
+ getUsername: () => user.username,
+ setLoading: jest.fn(),
+ setError: jest.fn(),
+ clearError: jest.fn(),
+ resetUser: jest.fn(),
+ ...overrides,
+ };
+
+ return state;
+};
+
+const setUserStoreState = (overrides?: Partial) => {
+ const state = createUserStoreState(overrides);
+ mockUseUserStore.mockImplementation(
+ (selector?: (s: UserStoreStateShape) => unknown, _equals?: unknown) =>
+ selector ? selector(state) : state
+ );
+ return state;
+};
+
+describe('useSportsTrends', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return undefined when username is not set', async () => {
+ setUserStoreState({
+ user: defaultUserProfile({ username: '' }),
+ });
+
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ const { result } = renderHook(() => useSportsTrends(), { wrapper });
+
+ expect(result.current.isPending).toBeTruthy();
+ expect(mockGetSportsTrends).not.toHaveBeenCalled();
+ });
+
+ it('should call getSportsTrends and return data when username is set', async () => {
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ setUserStoreState({
+ user: defaultUserProfile({ username: 'alice' }),
+ });
+
+ const sampleTrends: Trend[] = [
+ { hashtag: '#football', tweetsCount: 20000, category: 'sports' },
+ { hashtag: '#basketball', tweetsCount: 18000, category: 'sports' },
+ ];
+
+ mockGetSportsTrends.mockResolvedValue({
+ success: true,
+ data: sampleTrends,
+ });
+
+ const { result } = renderHook(() => useSportsTrends(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBeTruthy();
+ });
+
+ expect(mockGetSportsTrends).toHaveBeenCalledTimes(1);
+ expect(result.current.data).toEqual({
+ success: true,
+ data: sampleTrends,
+ });
+ });
+
+ it('should return error when getSportsTrends fails', async () => {
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ setUserStoreState({
+ user: defaultUserProfile({ username: 'alice' }),
+ });
+
+ const errorMessage = 'Failed to fetch sports trends';
+ mockGetSportsTrends.mockRejectedValue(new Error(errorMessage));
+
+ const { result } = renderHook(() => useSportsTrends(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isError).toBeTruthy();
+ });
+
+ expect(result.current.error).toBeDefined();
+ expect(mockGetSportsTrends).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/__tests__/hooks/explore/useTrending.test.tsx b/src/__tests__/hooks/explore/useTrending.test.tsx
new file mode 100644
index 000000000..e2df368f8
--- /dev/null
+++ b/src/__tests__/hooks/explore/useTrending.test.tsx
@@ -0,0 +1,179 @@
+import React from 'react';
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react-native';
+
+import { useTrending } from '@/hooks/explore/useTrending';
+import * as exploreService from '@/services/explore';
+import { useUserStore } from '@/stores/userStore';
+
+import type { Trend } from '@/types/explore';
+import type { UserProfile } from '@/types/user';
+
+jest.mock('@/stores/userStore', () => ({
+ useUserStore: jest.fn(),
+}));
+
+jest.mock('@/services/explore', () => ({
+ getTrending: jest.fn(),
+}));
+
+const mockUseUserStore = useUserStore as jest.MockedFunction;
+const mockGetTrending = exploreService.getTrending as jest.MockedFunction<
+ typeof exploreService.getTrending
+>;
+
+type UserStoreStateShape = {
+ user: UserProfile;
+ isLoading: boolean;
+ error: string | null;
+ updateUser: (data: Partial) => void;
+ setUser: (data: UserProfile) => void;
+ getUsername: () => string;
+ setLoading: (loading: boolean) => void;
+ setError: (error: string | null) => void;
+ clearError: () => void;
+ resetUser: () => void;
+};
+
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+const createWrapper = (queryClient: QueryClient) => {
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ Wrapper.displayName = 'QueryClientWrapper';
+ return Wrapper;
+};
+
+const defaultUserProfile = (overrides?: Partial): UserProfile => ({
+ username: '',
+ displayName: '',
+ bio: null,
+ bioEntities: null,
+ avatarUrl: 'http://www.gravatar.com/avatar/?d=mp',
+ bannerUrl: null,
+ location: null,
+ websiteUrl: null,
+ birthDate: null,
+ joinedAt: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ followingCount: 0,
+ followersCount: 0,
+ mutualsCount: 0,
+ mutualUsers: [],
+ ...overrides,
+});
+
+const createUserStoreState = (overrides?: Partial): UserStoreStateShape => {
+ const user = overrides?.user ?? defaultUserProfile();
+ const state: UserStoreStateShape = {
+ user,
+ isLoading: false,
+ error: null,
+ updateUser: jest.fn(),
+ setUser: jest.fn(),
+ getUsername: () => user.username,
+ setLoading: jest.fn(),
+ setError: jest.fn(),
+ clearError: jest.fn(),
+ resetUser: jest.fn(),
+ ...overrides,
+ };
+
+ return state;
+};
+
+const setUserStoreState = (overrides?: Partial) => {
+ const state = createUserStoreState(overrides);
+ mockUseUserStore.mockImplementation(
+ (selector?: (s: UserStoreStateShape) => unknown, _equals?: unknown) =>
+ selector ? selector(state) : state
+ );
+ return state;
+};
+
+describe('useTrending', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return undefined when username is not set', async () => {
+ setUserStoreState({
+ user: defaultUserProfile({ username: '' }),
+ });
+
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ const { result } = renderHook(() => useTrending(), { wrapper });
+
+ expect(result.current.isPending).toBeTruthy();
+ expect(mockGetTrending).not.toHaveBeenCalled();
+ });
+
+ it('should call getTrending and return data when username is set', async () => {
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ setUserStoreState({
+ user: defaultUserProfile({ username: 'alice' }),
+ });
+
+ const sampleTrends: Trend[] = [
+ { hashtag: '#trending1', tweetsCount: 15000, category: 'trending' },
+ { hashtag: '#trending2', tweetsCount: 12000, category: 'trending' },
+ ];
+
+ mockGetTrending.mockResolvedValue({
+ success: true,
+ data: sampleTrends,
+ });
+
+ const { result } = renderHook(() => useTrending(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBeTruthy();
+ });
+
+ expect(mockGetTrending).toHaveBeenCalledTimes(1);
+ expect(result.current.data).toEqual({
+ success: true,
+ data: sampleTrends,
+ });
+ });
+
+ it('should return error when getTrending fails', async () => {
+ const queryClient = createQueryClient();
+ const wrapper = createWrapper(queryClient);
+
+ setUserStoreState({
+ user: defaultUserProfile({ username: 'alice' }),
+ });
+
+ const errorMessage = 'Failed to fetch trending trends';
+ mockGetTrending.mockRejectedValue(new Error(errorMessage));
+
+ const { result } = renderHook(() => useTrending(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.isError).toBeTruthy();
+ });
+
+ expect(result.current.error).toBeDefined();
+ expect(mockGetTrending).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/__tests__/hooks/explore/useTrends.test.tsx b/src/__tests__/hooks/explore/useTrends.test.tsx
index 76183b0de..0f2d6a026 100644
--- a/src/__tests__/hooks/explore/useTrends.test.tsx
+++ b/src/__tests__/hooks/explore/useTrends.test.tsx
@@ -122,7 +122,7 @@ describe('useTrends', () => {
user: defaultUserProfile({ username: 'alice' }),
});
- const sampleTrends: Trend[] = [{ hashtag: 'react', tweetCount: 1200, category: 'technology' }];
+ const sampleTrends: Trend[] = [{ hashtag: 'react', tweetsCount: 1200, category: 'technology' }];
const fetcher: jest.Mock, []> = jest.fn().mockResolvedValue({
success: true,
diff --git a/src/__tests__/hooks/navigation/useBottomTabsConfig.test.tsx b/src/__tests__/hooks/navigation/useBottomTabsConfig.test.tsx
index 4c8382ce1..e65372256 100644
--- a/src/__tests__/hooks/navigation/useBottomTabsConfig.test.tsx
+++ b/src/__tests__/hooks/navigation/useBottomTabsConfig.test.tsx
@@ -4,8 +4,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, render, waitFor } from '@testing-library/react-native';
import { useBottomTabsConfig } from '@/hooks/navigation/useBottomTabsConfig';
+import { useNotificationsCount } from '@/hooks/notifications/useNotificationsCount';
import { ThemeProvider } from '@/hooks/useTheme';
+import { useDmStore } from '@/stores/dmStore';
import { useUserStore } from '@/stores/userStore';
+import { BottomTabsScreenOptionsProps } from '@/types/navigation';
import { BOTTOM_TABS } from '@/utils/navigation/routeNames';
jest.mock('@/components/ui/Logo', () => () => null);
@@ -19,7 +22,13 @@ jest.mock('@/hooks/navigation/useRootNavigation', () => ({
useRootNavigation: () => ({ navigate: jest.fn() }),
}));
-function HeaderHost() {
+jest.mock('@/hooks/notifications/useNotificationsCount', () => ({
+ useNotificationsCount: jest.fn(() => ({
+ data: { data: { unseenCount: 0 } },
+ })),
+}));
+
+function HeaderHost({ routeName }: { routeName: keyof typeof BOTTOM_TABS }) {
const screenOptions = useBottomTabsConfig();
const fakeNav = {
@@ -27,30 +36,77 @@ function HeaderHost() {
getState: () => ({
routes: [
{
- key: 'home-route',
- name: BOTTOM_TABS.HOME,
+ key: `${routeName}-route`,
+ name: BOTTOM_TABS[routeName],
params: undefined,
},
],
index: 0,
}),
- };
+ } as unknown as BottomTabsScreenOptionsProps<
+ (typeof BOTTOM_TABS)[typeof routeName]
+ >['navigation'];
const options = screenOptions({
- navigation: fakeNav as unknown as Parameters[0]['navigation'],
+ navigation: fakeNav,
route: {
- key: 'home-route',
- name: BOTTOM_TABS.HOME,
+ key: `${routeName}-route`,
+ name: BOTTOM_TABS[routeName],
params: undefined,
- } as unknown as Parameters[0]['route'],
+ } as unknown as BottomTabsScreenOptionsProps<(typeof BOTTOM_TABS)[typeof routeName]>['route'],
});
const Header = options.header as unknown as () => React.ReactNode;
return <>{Header && }>;
}
-describe('Header avatar updates on account switch', () => {
- it('renders user avatar and updates when userStore changes', async () => {
+function TabIconHost({
+ routeName,
+ focused,
+}: {
+ routeName: keyof typeof BOTTOM_TABS;
+ focused: boolean;
+}) {
+ const screenOptions = useBottomTabsConfig();
+
+ const fakeNav = {
+ getParent: () => ({ openDrawer: jest.fn() }),
+ getState: () => ({
+ routes: [{ key: `${routeName}-route`, name: BOTTOM_TABS[routeName], params: undefined }],
+ index: 0,
+ }),
+ } as unknown as BottomTabsScreenOptionsProps<
+ (typeof BOTTOM_TABS)[typeof routeName]
+ >['navigation'];
+
+ const options = screenOptions({
+ navigation: fakeNav,
+ route: {
+ key: `${routeName}-route`,
+ name: BOTTOM_TABS[routeName],
+ params: undefined,
+ } as unknown as BottomTabsScreenOptionsProps<(typeof BOTTOM_TABS)[typeof routeName]>['route'],
+ });
+
+ const TabBarIcon = options.tabBarIcon as (props: {
+ focused: boolean;
+ color: string;
+ size: number;
+ }) => React.ReactNode;
+
+ return <>{TabBarIcon && }>;
+}
+
+const queryClient = new QueryClient();
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+describe('useBottomTabsConfig', () => {
+ beforeEach(() => {
act(() => {
useUserStore.getState().setUser({
username: 'alice',
@@ -76,48 +132,186 @@ describe('Header avatar updates on account switch', () => {
mutualUsers: [],
});
});
+ });
- const queryClient = new QueryClient();
- const { getByTestId } = render(
-
-
-
-
-
- );
+ describe('Header rendering', () => {
+ it('renders logo for HOME tab', () => {
+ const { getByTestId } = render(
+
+
+
+ );
- const img1 = getByTestId('avatar-image');
- expect(img1.props.source).toEqual([{ uri: 'https://example.com/alice.png' }]);
+ expect(getByTestId('open-drawer-button')).toBeTruthy();
+ });
- await act(async () => {
- useUserStore.getState().setUser({
- username: 'bob',
- displayName: 'Bob',
- bio: null,
- bioEntities: null,
- avatarUrl: 'https://example.com/bob.png',
- bannerUrl: null,
- location: null,
- websiteUrl: null,
- birthDate: null,
- joinedAt: null,
- relationship: {
- blocking: false,
- blockedBy: false,
- muted: false,
- following: false,
- follower: false,
- },
- followingCount: 0,
- followersCount: 0,
- mutualsCount: 0,
- mutualUsers: [],
+ it('renders search trigger for EXPLORE tab', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('explore-search-trigger')).toBeTruthy();
+ expect(getByTestId('explore-search-trigger').props.accessibilityRole).toBe('button');
+ });
+
+ it('renders "Notifications" title for NOTIFICATIONS tab', () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('Notifications')).toBeTruthy();
+ });
+
+ it('renders "Messages" title for MESSAGES tab', () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('Messages')).toBeTruthy();
+ });
+ });
+
+ describe('TabBar icon with badges', () => {
+ it('renders icon without badge when unseenCount is 0', () => {
+ act(() => {
+ useDmStore.setState({ unseenCount: 0 });
});
+
+ const { queryByLabelText } = render(
+
+
+
+ );
+
+ expect(queryByLabelText('badge-count')).toBeNull();
+ });
+
+ it('renders badge with count for MESSAGES tab when unseenCount > 0', () => {
+ act(() => {
+ useDmStore.setState({ unseenCount: 5 });
+ });
+
+ const { getByLabelText } = render(
+
+
+
+ );
+
+ const badge = getByLabelText('badge-count');
+ expect(badge).toBeTruthy();
+ expect(badge.props.children).toBe('5');
});
- await waitFor(() => {
- const img2 = getByTestId('avatar-image');
- expect(img2.props.source).toEqual([{ uri: 'https://example.com/bob.png' }]);
+ it('renders badge with "99+" when unseenCount > 99', () => {
+ act(() => {
+ useDmStore.setState({ unseenCount: 150 });
+ });
+
+ const { getByLabelText } = render(
+
+
+
+ );
+
+ const badge = getByLabelText('badge-count');
+ expect(badge.props.children).toBe('99+');
+ });
+ it('renders badge for NOTIFICATIONS tab when unseenNotificationsCount > 0', () => {
+ (useNotificationsCount as jest.Mock).mockReturnValue({
+ data: { data: { unseenCount: 3 } },
+ });
+
+ const { getByLabelText } = render(
+
+
+
+ );
+
+ const badge = getByLabelText('badge-count');
+ expect(badge).toBeTruthy();
+ expect(badge.props.children).toBe('3');
+ });
+
+ it('does not render badge for HOME tab', () => {
+ const { queryByLabelText } = render(
+
+
+
+ );
+
+ expect(queryByLabelText('badge-count')).toBeNull();
+ });
+
+ it('renders active icon when tab is focused', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const icon = getByTestId('mock-icon-home-sharp');
+ expect(icon).toBeTruthy();
+ });
+
+ it('renders inactive icon when tab is not focused', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const icon = getByTestId('mock-icon-home-outline');
+ expect(icon).toBeTruthy();
+ });
+ });
+
+ describe('Avatar updates on account switch', () => {
+ it('renders user avatar and updates when userStore changes', async () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const img1 = getByTestId('avatar-image');
+ expect(img1.props.source).toEqual([{ uri: 'https://example.com/alice.png' }]);
+
+ await act(async () => {
+ useUserStore.getState().setUser({
+ username: 'bob',
+ displayName: 'Bob',
+ bio: null,
+ bioEntities: null,
+ avatarUrl: 'https://example.com/bob.png',
+ bannerUrl: null,
+ location: null,
+ websiteUrl: null,
+ birthDate: null,
+ joinedAt: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ followingCount: 0,
+ followersCount: 0,
+ mutualsCount: 0,
+ mutualUsers: [],
+ });
+ });
+
+ await waitFor(() => {
+ const img2 = getByTestId('avatar-image');
+ expect(img2.props.source).toEqual([{ uri: 'https://example.com/bob.png' }]);
+ });
});
});
});
diff --git a/src/__tests__/hooks/navigation/useDrawerNavigation.test.tsx b/src/__tests__/hooks/navigation/useDrawerNavigation.test.tsx
new file mode 100644
index 000000000..82fe89c42
--- /dev/null
+++ b/src/__tests__/hooks/navigation/useDrawerNavigation.test.tsx
@@ -0,0 +1,48 @@
+import { useNavigation } from '@react-navigation/native';
+import { renderHook } from '@testing-library/react-native';
+
+import { useDrawerNavigation } from '@/hooks/navigation/useDrawerNavigation';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: jest.fn(),
+}));
+
+const mockUseNavigation = useNavigation as jest.MockedFunction;
+
+describe('useDrawerNavigation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns the navigation object from useNavigation', () => {
+ const mockNavigation = {
+ openDrawer: jest.fn(),
+ closeDrawer: jest.fn(),
+ toggleDrawer: jest.fn(),
+ navigate: jest.fn(),
+ goBack: jest.fn(),
+ } as unknown as ReturnType;
+
+ mockUseNavigation.mockReturnValue(mockNavigation);
+
+ const { result } = renderHook(() => useDrawerNavigation());
+
+ expect(result.current).toBe(mockNavigation);
+ });
+
+ it('provides drawer navigation methods', () => {
+ const mockNavigation = {
+ openDrawer: jest.fn(),
+ closeDrawer: jest.fn(),
+ toggleDrawer: jest.fn(),
+ } as unknown as ReturnType;
+
+ mockUseNavigation.mockReturnValue(mockNavigation);
+
+ const { result } = renderHook(() => useDrawerNavigation());
+
+ expect(result.current.openDrawer).toBeDefined();
+ expect(result.current.closeDrawer).toBeDefined();
+ expect(result.current.toggleDrawer).toBeDefined();
+ });
+});
diff --git a/src/__tests__/hooks/navigation/useDrawerSwipe.test.tsx b/src/__tests__/hooks/navigation/useDrawerSwipe.test.tsx
new file mode 100644
index 000000000..e5c449df2
--- /dev/null
+++ b/src/__tests__/hooks/navigation/useDrawerSwipe.test.tsx
@@ -0,0 +1,61 @@
+import { renderHook } from '@testing-library/react-native';
+
+import { useDrawerNavigation } from '@/hooks/navigation/useDrawerNavigation';
+import { useDrawerSwipe } from '@/hooks/navigation/useDrawerSwipe';
+import { useHorizontalSwipe } from '@/hooks/navigation/useHorizontalSwipe';
+
+jest.mock('@/hooks/navigation/useDrawerNavigation');
+jest.mock('@/hooks/navigation/useHorizontalSwipe');
+
+const mockUseDrawerNavigation = useDrawerNavigation as jest.MockedFunction<
+ typeof useDrawerNavigation
+>;
+const mockUseHorizontalSwipe = useHorizontalSwipe as jest.MockedFunction;
+
+describe('useDrawerSwipe', () => {
+ let openDrawer: jest.Mock;
+ let onSwipeTabCallback: ((direction: 'next' | 'previous') => void) | null;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ onSwipeTabCallback = null;
+
+ openDrawer = jest.fn();
+ mockUseDrawerNavigation.mockReturnValue({
+ openDrawer,
+ } as unknown as ReturnType);
+
+ mockUseHorizontalSwipe.mockImplementation(({ onSwipeTab }) => {
+ onSwipeTabCallback = onSwipeTab;
+ return {
+ onTouchStart: jest.fn(),
+ onTouchEnd: jest.fn(),
+ };
+ });
+ });
+
+ it('calls openDrawer when swiping previous', () => {
+ renderHook(() => useDrawerSwipe());
+
+ expect(onSwipeTabCallback).toBeDefined();
+ onSwipeTabCallback!('previous');
+
+ expect(openDrawer).toHaveBeenCalledTimes(1);
+ });
+
+ it('does nothing when swiping next', () => {
+ renderHook(() => useDrawerSwipe());
+
+ expect(onSwipeTabCallback).toBeDefined();
+ onSwipeTabCallback!('next');
+
+ expect(openDrawer).not.toHaveBeenCalled();
+ });
+
+ it('returns onTouchStart and onTouchEnd from useHorizontalSwipe', () => {
+ const { result } = renderHook(() => useDrawerSwipe());
+
+ expect(result.current.onTouchStart).toBeDefined();
+ expect(result.current.onTouchEnd).toBeDefined();
+ });
+});
diff --git a/src/__tests__/hooks/navigation/useHorizontalSwipe.test.tsx b/src/__tests__/hooks/navigation/useHorizontalSwipe.test.tsx
new file mode 100644
index 000000000..99cd18628
--- /dev/null
+++ b/src/__tests__/hooks/navigation/useHorizontalSwipe.test.tsx
@@ -0,0 +1,71 @@
+import { GestureResponderEvent, Platform } from 'react-native';
+
+import { renderHook } from '@testing-library/react-native';
+
+import { useHorizontalSwipe } from '@/hooks/navigation/useHorizontalSwipe';
+
+const createTouchEvent = (pageX: number, pageY: number): GestureResponderEvent => {
+ return {
+ nativeEvent: {
+ pageX,
+ pageY,
+ },
+ } as GestureResponderEvent;
+};
+
+describe('useHorizontalSwipe', () => {
+ const _SWIPE_DISTANCE = Platform.OS === 'android' ? 10 : 150;
+
+ it('detects right swipe (next)', () => {
+ const onSwipeTab = jest.fn();
+ const { result } = renderHook(() => useHorizontalSwipe({ onSwipeTab }));
+
+ result.current.onTouchStart(createTouchEvent(200, 100));
+ result.current.onTouchEnd(createTouchEvent(0, 100));
+
+ expect(onSwipeTab).toHaveBeenCalledWith('next');
+ expect(onSwipeTab).toHaveBeenCalledTimes(1);
+ });
+
+ it('detects left swipe (previous)', () => {
+ const onSwipeTab = jest.fn();
+ const { result } = renderHook(() => useHorizontalSwipe({ onSwipeTab }));
+
+ result.current.onTouchStart(createTouchEvent(50, 100)); // End touch at x=250 (swiped left to right by 200+)
+ result.current.onTouchEnd(createTouchEvent(250, 100));
+
+ expect(onSwipeTab).toHaveBeenCalledWith('previous');
+ expect(onSwipeTab).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not trigger swipe when distance is too small', () => {
+ const onSwipeTab = jest.fn();
+ const { result } = renderHook(() => useHorizontalSwipe({ onSwipeTab }));
+
+ result.current.onTouchStart(createTouchEvent(100, 100));
+ result.current.onTouchEnd(createTouchEvent(95, 100));
+
+ expect(onSwipeTab).not.toHaveBeenCalled();
+ });
+
+ it('does not trigger swipe when vertical scroll distance exceeds threshold', () => {
+ const onSwipeTab = jest.fn();
+ const { result } = renderHook(() => useHorizontalSwipe({ onSwipeTab }));
+
+ result.current.onTouchStart(createTouchEvent(200, 100));
+ result.current.onTouchEnd(createTouchEvent(50, 350));
+
+ expect(onSwipeTab).not.toHaveBeenCalled();
+ });
+
+ it('triggers swipe when vertical scroll is within threshold', () => {
+ const onSwipeTab = jest.fn();
+ const { result } = renderHook(() => useHorizontalSwipe({ onSwipeTab }));
+
+ result.current.onTouchStart(createTouchEvent(200, 100));
+ result.current.onTouchEnd(createTouchEvent(0, 250));
+
+ expect(onSwipeTab).toHaveBeenCalledWith('next');
+ expect(onSwipeTab).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/__tests__/hooks/navigation/useTopTabsConfig.test.tsx b/src/__tests__/hooks/navigation/useTopTabsConfig.test.tsx
new file mode 100644
index 000000000..e1731db0f
--- /dev/null
+++ b/src/__tests__/hooks/navigation/useTopTabsConfig.test.tsx
@@ -0,0 +1,106 @@
+import { renderHook } from '@testing-library/react-native';
+
+import { useTopTabsConfig } from '@/hooks/navigation/useTopTabsConfig';
+import { useTheme } from '@/hooks/useTheme';
+import { colors } from '@/utils/colorTheme';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: jest.fn(),
+}));
+const mockUseTheme = useTheme as jest.MockedFunction;
+
+describe('useTopTabsConfig', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns correct options for light theme', () => {
+ mockUseTheme.mockReturnValue({ theme: 'light', setTheme: jest.fn(), selectedTheme: 'light' });
+
+ const { result } = renderHook(() => useTopTabsConfig());
+ const opts = result.current;
+
+ const scheme = colors.light;
+
+ expect(opts.tabBarStyle).toMatchObject({
+ backgroundColor: scheme.background,
+ elevation: 0,
+ });
+
+ expect(opts.tabBarItemStyle).toMatchObject({
+ marginHorizontal: 5,
+ });
+
+ expect(opts.tabBarIndicatorStyle).toMatchObject({
+ backgroundColor: scheme.primary,
+ borderColor: scheme.background,
+ height: 3,
+ borderRadius: 10,
+ });
+
+ expect(opts.tabBarLabelStyle).toMatchObject({
+ fontWeight: 'bold',
+ fontSize: 15,
+ });
+
+ // Light theme uses border color and width 1
+ expect(opts.tabBarContentContainerStyle).toMatchObject({
+ borderBottomColor: scheme.border,
+ borderBottomWidth: 1,
+ });
+
+ expect(opts.tabBarActiveTintColor).toBe(scheme.foreground);
+ expect(opts.tabBarInactiveTintColor).toBe(scheme.mutedForeground);
+ });
+
+ it('returns correct options for dark theme', () => {
+ mockUseTheme.mockReturnValue({ theme: 'dark', setTheme: jest.fn(), selectedTheme: 'dark' });
+
+ const { result } = renderHook(() => useTopTabsConfig());
+ const opts = result.current;
+
+ const scheme = colors.dark;
+
+ expect(opts.tabBarStyle).toMatchObject({
+ backgroundColor: scheme.background,
+ elevation: 0,
+ });
+
+ expect(opts.tabBarItemStyle).toMatchObject({
+ marginHorizontal: 5,
+ });
+
+ expect(opts.tabBarIndicatorStyle).toMatchObject({
+ backgroundColor: scheme.primary,
+ borderColor: scheme.background,
+ height: 3,
+ borderRadius: 10,
+ });
+
+ expect(opts.tabBarLabelStyle).toMatchObject({
+ fontWeight: 'bold',
+ fontSize: 15,
+ });
+
+ expect(opts.tabBarContentContainerStyle).toMatchObject({
+ borderBottomColor: scheme.mutedForeground,
+ borderBottomWidth: 0.3,
+ });
+
+ expect(opts.tabBarActiveTintColor).toBe(scheme.foreground);
+ expect(opts.tabBarInactiveTintColor).toBe(scheme.mutedForeground);
+ expect(opts.tabBarActiveTintColor).toBe(scheme.foreground);
+ expect(opts.tabBarInactiveTintColor).toBe(scheme.mutedForeground);
+ });
+
+ it('memoizes options across calls with same theme', () => {
+ const { result, rerender } = renderHook(() => useTopTabsConfig());
+ const first = result.current;
+
+ rerender(result.current);
+
+ const second = result.current;
+
+ expect(second).toBe(first);
+ });
+});
diff --git a/src/__tests__/hooks/notifications/useMarkAllNotificationsAsSeen.test.tsx b/src/__tests__/hooks/notifications/useMarkAllNotificationsAsSeen.test.tsx
new file mode 100644
index 000000000..b29fb52bb
--- /dev/null
+++ b/src/__tests__/hooks/notifications/useMarkAllNotificationsAsSeen.test.tsx
@@ -0,0 +1,309 @@
+import { ReactNode } from 'react';
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+
+import { useMarkAllNotificationsAsSeen } from '@/hooks/notifications/useMarkAllNotificationsAsSeen';
+import * as notificationService from '@/services/notifications';
+
+jest.mock('@/services/notifications');
+
+const Wrapper = ({ children }: { children: ReactNode }) => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ return {children} ;
+};
+
+describe('useMarkAllNotificationsAsSeen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should mark all notifications as seen successfully', async () => {
+ (notificationService.markAllNotificationsAsSeen as jest.Mock).mockResolvedValue({
+ success: true,
+ data: { updatedCount: 5 },
+ });
+
+ const { result } = renderHook(() => useMarkAllNotificationsAsSeen(), {
+ wrapper: Wrapper,
+ });
+
+ act(() => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(notificationService.markAllNotificationsAsSeen).toHaveBeenCalled();
+ });
+
+ it('should handle errors when marking all as seen', async () => {
+ const mockError = new Error('Failed to mark all as seen');
+ (notificationService.markAllNotificationsAsSeen as jest.Mock).mockRejectedValue(mockError);
+
+ const { result } = renderHook(() => useMarkAllNotificationsAsSeen(), {
+ wrapper: Wrapper,
+ });
+
+ act(() => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ expect(result.current.error).toEqual(mockError);
+ });
+
+ it('should update all cached notifications to seen on success', async () => {
+ (notificationService.markAllNotificationsAsSeen as jest.Mock).mockResolvedValue({
+ success: true,
+ data: { updatedCount: 2 },
+ });
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ // Set initial notifications data with unseen notifications
+ queryClient.setQueryData(['notifications', { username: 'testuser' }], {
+ pages: [
+ {
+ data: [
+ { id: 'notif-1', isSeen: false, type: 'FOLLOW' },
+ { id: 'notif-2', isSeen: false, type: 'LIKE' },
+ { id: 'notif-3', isSeen: true, type: 'REPLY' },
+ ],
+ },
+ ],
+ pageParams: [undefined],
+ });
+
+ const CustomWrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useMarkAllNotificationsAsSeen(), {
+ wrapper: CustomWrapper,
+ });
+
+ act(() => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const updatedData = queryClient.getQueryData(['notifications', { username: 'testuser' }]) as {
+ pages: { data: { id: string; isSeen: boolean }[] }[];
+ };
+
+ // All notifications should be marked as seen
+ expect(updatedData.pages[0].data[0].isSeen).toBe(true);
+ expect(updatedData.pages[0].data[1].isSeen).toBe(true);
+ expect(updatedData.pages[0].data[2].isSeen).toBe(true);
+ });
+
+ it('should reset unseen count to 0 on success', async () => {
+ (notificationService.markAllNotificationsAsSeen as jest.Mock).mockResolvedValue({
+ success: true,
+ data: { updatedCount: 5 },
+ });
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ // Set initial count data
+ queryClient.setQueryData(['notifications', 'count'], {
+ data: {
+ unseenCount: 10,
+ },
+ });
+
+ const CustomWrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useMarkAllNotificationsAsSeen(), {
+ wrapper: CustomWrapper,
+ });
+
+ act(() => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const count = queryClient.getQueryData(['notifications', 'count']) as {
+ data: { unseenCount: number };
+ };
+ expect(count.data.unseenCount).toBe(0);
+ });
+
+ it('should handle undefined notifications data gracefully', async () => {
+ (notificationService.markAllNotificationsAsSeen as jest.Mock).mockResolvedValue({
+ success: true,
+ data: { updatedCount: 0 },
+ });
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ // No notifications data set
+ const CustomWrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useMarkAllNotificationsAsSeen(), {
+ wrapper: CustomWrapper,
+ });
+
+ act(() => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ });
+
+ it('should handle undefined count data gracefully', async () => {
+ (notificationService.markAllNotificationsAsSeen as jest.Mock).mockResolvedValue({
+ success: true,
+ data: { updatedCount: 0 },
+ });
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ // Set undefined count
+ queryClient.setQueryData(['notifications', 'count'], undefined);
+
+ const CustomWrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useMarkAllNotificationsAsSeen(), {
+ wrapper: CustomWrapper,
+ });
+
+ act(() => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const count = queryClient.getQueryData(['notifications', 'count']);
+ expect(count).toBeUndefined();
+ });
+
+ it('should handle count data without data property', async () => {
+ (notificationService.markAllNotificationsAsSeen as jest.Mock).mockResolvedValue({
+ success: true,
+ data: { updatedCount: 0 },
+ });
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ // Set count data without data property
+ queryClient.setQueryData(['notifications', 'count'], { someOtherProp: 'value' });
+
+ const CustomWrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useMarkAllNotificationsAsSeen(), {
+ wrapper: CustomWrapper,
+ });
+
+ act(() => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const count = queryClient.getQueryData(['notifications', 'count']) as {
+ someOtherProp?: string;
+ };
+ expect(count?.someOtherProp).toBe('value');
+ });
+
+ it('should update notifications across multiple pages', async () => {
+ (notificationService.markAllNotificationsAsSeen as jest.Mock).mockResolvedValue({
+ success: true,
+ data: { updatedCount: 4 },
+ });
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ // Set initial notifications data with multiple pages
+ queryClient.setQueryData(['notifications', { username: 'testuser' }], {
+ pages: [
+ {
+ data: [
+ { id: 'notif-1', isSeen: false, type: 'FOLLOW' },
+ { id: 'notif-2', isSeen: false, type: 'LIKE' },
+ ],
+ },
+ {
+ data: [
+ { id: 'notif-3', isSeen: false, type: 'REPLY' },
+ { id: 'notif-4', isSeen: false, type: 'RETWEET' },
+ ],
+ },
+ ],
+ pageParams: [undefined, 'cursor1'],
+ });
+
+ const CustomWrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useMarkAllNotificationsAsSeen(), {
+ wrapper: CustomWrapper,
+ });
+
+ act(() => {
+ result.current.mutate();
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const updatedData = queryClient.getQueryData(['notifications', { username: 'testuser' }]) as {
+ pages: { data: { id: string; isSeen: boolean }[] }[];
+ };
+
+ // All notifications across all pages should be marked as seen
+ expect(updatedData.pages[0].data[0].isSeen).toBe(true);
+ expect(updatedData.pages[0].data[1].isSeen).toBe(true);
+ expect(updatedData.pages[1].data[0].isSeen).toBe(true);
+ expect(updatedData.pages[1].data[1].isSeen).toBe(true);
+ });
+});
diff --git a/src/__tests__/hooks/profile/useFollowMutation.test.tsx b/src/__tests__/hooks/profile/useFollowMutation.test.tsx
index 5d1555456..70c37d29f 100644
--- a/src/__tests__/hooks/profile/useFollowMutation.test.tsx
+++ b/src/__tests__/hooks/profile/useFollowMutation.test.tsx
@@ -277,8 +277,22 @@ describe('useFollowMutation', () => {
pages: [
{
data: [
- { username: 'targetUser', displayName: 'Target User', isFollowing: false },
- { username: 'otherUser', displayName: 'Other User', isFollowing: true },
+ {
+ username: 'targetUser',
+ displayName: 'Target User',
+ bio: null,
+ avatarUrl: null,
+ bioEntities: null,
+ relationship: { ...rel, following: false },
+ },
+ {
+ username: 'otherUser',
+ displayName: 'Other User',
+ bio: null,
+ avatarUrl: null,
+ bioEntities: null,
+ relationship: { ...rel, following: true },
+ },
],
nextCursor: undefined,
},
@@ -299,8 +313,8 @@ describe('useFollowMutation', () => {
'tweetLikers',
'tweet-123',
]) as typeof likersData;
- expect(updatedLikers.pages[0].data[0].isFollowing).toBe(true);
- expect(updatedLikers.pages[0].data[1].isFollowing).toBe(true); // unchanged
+ expect(updatedLikers.pages[0].data[0].relationship?.following).toBe(true);
+ expect(updatedLikers.pages[0].data[1].relationship?.following).toBe(true); // unchanged
});
it('updates tweetRetweeters cache when following a user', async () => {
@@ -322,8 +336,22 @@ describe('useFollowMutation', () => {
pages: [
{
data: [
- { username: 'retweeter', displayName: 'Retweeter', isFollowing: false },
- { username: 'anotherUser', displayName: 'Another', isFollowing: false },
+ {
+ username: 'retweeter',
+ displayName: 'Retweeter',
+ bio: null,
+ avatarUrl: null,
+ bioEntities: null,
+ relationship: { ...rel, following: false },
+ },
+ {
+ username: 'anotherUser',
+ displayName: 'Another',
+ bio: null,
+ avatarUrl: null,
+ bioEntities: null,
+ relationship: { ...rel, following: false },
+ },
],
nextCursor: undefined,
},
@@ -344,8 +372,8 @@ describe('useFollowMutation', () => {
'tweetRetweeters',
'tweet-456',
]) as typeof retweetersData;
- expect(updatedRetweeters.pages[0].data[0].isFollowing).toBe(true);
- expect(updatedRetweeters.pages[0].data[1].isFollowing).toBe(false);
+ expect(updatedRetweeters.pages[0].data[0].relationship?.following).toBe(true);
+ expect(updatedRetweeters.pages[0].data[1].relationship?.following).toBe(false);
});
it('creates default relationship when target profile has no relationship', async () => {
@@ -388,7 +416,7 @@ describe('useFollowMutation', () => {
blocking: false,
blockedBy: false,
muted: false,
- follower: null,
+ follower: undefined, // Changed from null to undefined
following: true,
});
expect(updatedProfile.followersCount).toBe(11);
@@ -446,7 +474,16 @@ describe('useFollowMutation', () => {
const likersData = {
pages: [
{
- data: [{ username: 'targetUser', displayName: 'Target', isFollowing: false }],
+ data: [
+ {
+ username: 'targetUser',
+ displayName: 'Target',
+ bio: null,
+ avatarUrl: null,
+ bioEntities: null,
+ relationship: { ...rel, following: false },
+ },
+ ],
nextCursor: undefined,
},
],
@@ -457,7 +494,16 @@ describe('useFollowMutation', () => {
const retweetersData = {
pages: [
{
- data: [{ username: 'targetUser', displayName: 'Target', isFollowing: false }],
+ data: [
+ {
+ username: 'targetUser',
+ displayName: 'Target',
+ bio: null,
+ avatarUrl: null,
+ bioEntities: null,
+ relationship: { ...rel, following: false },
+ },
+ ],
nextCursor: undefined,
},
],
@@ -484,8 +530,8 @@ describe('useFollowMutation', () => {
'tweet-789',
]) as typeof retweetersData;
- expect(revertedLikers.pages[0].data[0].isFollowing).toBe(false);
- expect(revertedRetweeters.pages[0].data[0].isFollowing).toBe(false);
+ expect(revertedLikers.pages[0].data[0].relationship?.following).toBe(false);
+ expect(revertedRetweeters.pages[0].data[0].relationship?.following).toBe(false);
});
it('handles mutation without target profile in cache', async () => {
@@ -528,7 +574,13 @@ describe('useFollowMutation', () => {
const followersData = {
pages: [
{
- data: [{ username: 'followedUser', displayName: 'Followed', isFollowing: true }],
+ data: [
+ createProfile({
+ username: 'followedUser',
+ displayName: 'Followed',
+ relationship: { ...rel, following: true },
+ }),
+ ],
nextCursor: undefined,
},
],
@@ -554,6 +606,7 @@ describe('useFollowMutation', () => {
'followers',
'someUser',
]) as typeof followersData;
- expect(updatedFollowers.pages[0].data[0].isFollowing).toBe(false);
+ // Use createProfile() so the data structure matches what updateConnectionsLists expects
+ expect(updatedFollowers.pages[0].data[0].relationship?.following).toBe(false);
});
});
diff --git a/src/__tests__/hooks/profile/useUserBlocks.test.tsx b/src/__tests__/hooks/profile/useUserBlocks.test.tsx
index 64bacf41f..1bb1273bb 100644
--- a/src/__tests__/hooks/profile/useUserBlocks.test.tsx
+++ b/src/__tests__/hooks/profile/useUserBlocks.test.tsx
@@ -64,6 +64,13 @@ describe('useUserBlocks', () => {
avatarUrl: null,
displayName: 'user1',
bioEntities: null,
+ relationship: {
+ blocking: true,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
{
username: 'user2',
@@ -71,6 +78,13 @@ describe('useUserBlocks', () => {
avatarUrl: null,
displayName: 'user2',
bioEntities: null,
+ relationship: {
+ blocking: true,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
],
pagination: { hasNextPage: true, nextCursor: 'cursor-2' },
@@ -85,6 +99,13 @@ describe('useUserBlocks', () => {
avatarUrl: null,
displayName: 'user3',
bioEntities: null,
+ relationship: {
+ blocking: true,
+ blockedBy: false,
+ muted: true,
+ following: false,
+ follower: false,
+ },
},
],
pagination: { hasNextPage: false, nextCursor: null },
diff --git a/src/__tests__/hooks/profile/useUserFollowers.test.tsx b/src/__tests__/hooks/profile/useUserFollowers.test.tsx
index 678dbe6a2..ec18a1e7f 100644
--- a/src/__tests__/hooks/profile/useUserFollowers.test.tsx
+++ b/src/__tests__/hooks/profile/useUserFollowers.test.tsx
@@ -44,9 +44,13 @@ describe('useUserFollowers', () => {
bio: 'Bio',
avatarUrl: 'avatar.jpg',
bioEntities: null,
- isFollowing: false,
- followsYou: true,
- isBlocked: false,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: true,
+ },
},
],
pagination: {
diff --git a/src/__tests__/hooks/profile/useUserFollowing.test.tsx b/src/__tests__/hooks/profile/useUserFollowing.test.tsx
index 1cea22d3d..405380fd8 100644
--- a/src/__tests__/hooks/profile/useUserFollowing.test.tsx
+++ b/src/__tests__/hooks/profile/useUserFollowing.test.tsx
@@ -40,9 +40,13 @@ describe('useUserFollowing', () => {
bio: 'Bio',
avatarUrl: 'avatar.jpg',
bioEntities: null,
- isFollowing: true,
- followsYou: false,
- isBlocked: false,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: true,
+ follower: false,
+ },
},
],
pagination: {
diff --git a/src/__tests__/hooks/profile/useUserLikes.test.tsx b/src/__tests__/hooks/profile/useUserLikes.test.tsx
index 94c18cff5..9762af3bd 100644
--- a/src/__tests__/hooks/profile/useUserLikes.test.tsx
+++ b/src/__tests__/hooks/profile/useUserLikes.test.tsx
@@ -40,6 +40,13 @@ describe('useUserLikes', () => {
username: 'otheruser',
displayName: 'Other User',
avatarUrl: 'avatar.jpg',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
content: 'Liked tweet',
createdAt: '2023-01-01T00:00:00Z',
diff --git a/src/__tests__/hooks/profile/useUserMedia.test.tsx b/src/__tests__/hooks/profile/useUserMedia.test.tsx
index a9f4a3d5b..9661f3129 100644
--- a/src/__tests__/hooks/profile/useUserMedia.test.tsx
+++ b/src/__tests__/hooks/profile/useUserMedia.test.tsx
@@ -40,6 +40,13 @@ describe('useUserMedia', () => {
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'avatar.jpg',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
content: 'Test media tweet',
createdAt: '2023-01-01T00:00:00Z',
diff --git a/src/__tests__/hooks/profile/useUserMutes.test.tsx b/src/__tests__/hooks/profile/useUserMutes.test.tsx
index 10e6a270c..9eca6c245 100644
--- a/src/__tests__/hooks/profile/useUserMutes.test.tsx
+++ b/src/__tests__/hooks/profile/useUserMutes.test.tsx
@@ -60,6 +60,13 @@ describe('useUserMutes', () => {
avatarUrl: null,
displayName: 'user1',
bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: true,
+ following: false,
+ follower: false,
+ },
},
{
username: 'user2',
@@ -67,6 +74,13 @@ describe('useUserMutes', () => {
avatarUrl: null,
displayName: 'user2',
bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: true,
+ following: false,
+ follower: false,
+ },
},
],
pagination: { hasNextPage: true, nextCursor: 'cursor-2' },
@@ -81,6 +95,34 @@ describe('useUserMutes', () => {
avatarUrl: null,
displayName: 'user3',
bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: true,
+ following: false,
+ follower: false,
+ },
+ },
+ ],
+ pagination: { hasNextPage: true, nextCursor: 'cursor-2' },
+ message: 'ok',
+ })
+ .mockResolvedValueOnce({
+ success: true,
+ data: [
+ {
+ username: 'user3',
+ bio: null,
+ avatarUrl: null,
+ displayName: 'user3',
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: true,
+ following: false,
+ follower: false,
+ },
},
],
pagination: { hasNextPage: false, nextCursor: null },
@@ -102,12 +144,4 @@ describe('useUserMutes', () => {
const lastCallArgs = mockGetUserMutes.mock.calls[1];
expect(lastCallArgs[0]).toBe('cursor-2');
});
-
- it('shows error state when service throws', async () => {
- mockGetUserMutes.mockRejectedValueOnce(new Error('boom'));
-
- const { getByTestId } = render( , { wrapper: createWrapper() });
-
- await waitFor(() => expect(getByTestId('error')).toBeTruthy());
- });
});
diff --git a/src/__tests__/hooks/profile/useUserMutuals.test.tsx b/src/__tests__/hooks/profile/useUserMutuals.test.tsx
index 22ff6b041..58e5a8b84 100644
--- a/src/__tests__/hooks/profile/useUserMutuals.test.tsx
+++ b/src/__tests__/hooks/profile/useUserMutuals.test.tsx
@@ -40,9 +40,13 @@ describe('useUserMutuals', () => {
bio: 'Bio',
avatarUrl: 'avatar.jpg',
bioEntities: null,
- isFollowing: true,
- followsYou: true,
- isBlocked: false,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: true,
+ follower: true,
+ },
},
],
pagination: {
diff --git a/src/__tests__/hooks/profile/useUserReplies.test.tsx b/src/__tests__/hooks/profile/useUserReplies.test.tsx
index 3427e9c1a..49f72c68a 100644
--- a/src/__tests__/hooks/profile/useUserReplies.test.tsx
+++ b/src/__tests__/hooks/profile/useUserReplies.test.tsx
@@ -40,6 +40,15 @@ describe('useUserReplies', () => {
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'avatar.jpg',
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: undefined,
+ blockedBy: undefined,
+ muted: undefined,
+ following: undefined,
+ follower: undefined,
+ },
},
content: 'Test reply',
createdAt: '2023-01-01T00:00:00Z',
diff --git a/src/__tests__/hooks/profile/useUserTweets.test.tsx b/src/__tests__/hooks/profile/useUserTweets.test.tsx
index 8da580da6..70d1a5641 100644
--- a/src/__tests__/hooks/profile/useUserTweets.test.tsx
+++ b/src/__tests__/hooks/profile/useUserTweets.test.tsx
@@ -40,6 +40,15 @@ describe('useUserTweets', () => {
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'avatar.jpg',
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: undefined,
+ blockedBy: undefined,
+ muted: undefined,
+ following: undefined,
+ follower: undefined,
+ },
},
content: 'Test tweet',
createdAt: '2023-01-01T00:00:00Z',
diff --git a/src/__tests__/hooks/tweets/useTweetDetail.test.tsx b/src/__tests__/hooks/tweets/useTweetDetail.test.tsx
new file mode 100644
index 000000000..ca088e68c
--- /dev/null
+++ b/src/__tests__/hooks/tweets/useTweetDetail.test.tsx
@@ -0,0 +1,119 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react-native';
+
+import { useTweetDetail } from '@/hooks/tweets/useTweetDetail';
+import { getTweetCache } from '@/libs/tweetCache';
+import { getTweet } from '@/services/tweets';
+
+// Mock dependencies
+jest.mock('@/services/tweets', () => ({
+ getTweet: jest.fn(),
+}));
+
+jest.mock('@/libs/tweetCache', () => ({
+ getTweetCache: jest.fn(() => ({
+ setTweet: jest.fn(),
+ setTweets: jest.fn(),
+ })),
+}));
+
+describe('useTweetDetail', () => {
+ let queryClient: QueryClient;
+ const mockTweetCache = {
+ setTweet: jest.fn(),
+ setTweets: jest.fn(),
+ };
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ retryDelay: 0, // No delay between retries for fast testing
+ },
+ },
+ });
+ jest.clearAllMocks();
+ (getTweetCache as jest.Mock).mockReturnValue(mockTweetCache);
+ });
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ it('fetches and caches tweet successfully', async () => {
+ const mockTweet = {
+ id: '123',
+ content: 'test tweet',
+ author: { id: 'u1', username: 'user1' },
+ rootTweet: { id: 'root1', content: 'root tweet' },
+ parentTweets: [{ id: 'p1', content: 'parent tweet' }],
+ };
+
+ (getTweet as jest.Mock).mockResolvedValue({
+ success: true,
+ data: mockTweet,
+ });
+
+ const { result } = renderHook(() => useTweetDetail('123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data).toEqual(mockTweet);
+ expect(mockTweetCache.setTweet).toHaveBeenCalledWith(mockTweet);
+ expect(mockTweetCache.setTweet).toHaveBeenCalledWith(mockTweet.rootTweet);
+ expect(mockTweetCache.setTweets).toHaveBeenCalledWith(mockTweet.parentTweets);
+ });
+
+ it('does not cache deleted root/parent tweets', async () => {
+ const mockTweet = {
+ id: '123',
+ content: 'test tweet',
+ rootTweet: { id: 'root1', isDeleted: true },
+ parentTweets: [
+ { id: 'p1', content: 'parent tweet' },
+ { id: 'p2', isDeleted: true },
+ ],
+ };
+
+ (getTweet as jest.Mock).mockResolvedValue({
+ success: true,
+ data: mockTweet,
+ });
+
+ const { result } = renderHook(() => useTweetDetail('123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(mockTweetCache.setTweet).toHaveBeenCalledWith(mockTweet);
+ expect(mockTweetCache.setTweet).not.toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'root1' })
+ );
+ expect(mockTweetCache.setTweets).toHaveBeenCalledWith([{ id: 'p1', content: 'parent tweet' }]);
+ });
+
+ it('handles 404 error by preventing retry', async () => {
+ interface ErrorWithStatus extends Error {
+ status: number;
+ }
+ const error = new Error('Not found') as ErrorWithStatus;
+ error.status = 404;
+ (getTweet as jest.Mock).mockRejectedValue(error);
+
+ const { result } = renderHook(() => useTweetDetail('123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(result.current.failureCount).toBe(1); // Should fail once and not retry
+ });
+
+ it('throws error when response is not success', async () => {
+ (getTweet as jest.Mock).mockResolvedValue({
+ success: false,
+ });
+
+ const { result } = renderHook(() => useTweetDetail('123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(result.current.error).toEqual(new Error('Failed to fetch tweet'));
+ });
+});
diff --git a/src/__tests__/hooks/tweets/useTweetLikers.test.tsx b/src/__tests__/hooks/tweets/useTweetLikers.test.tsx
index 8c0ce4f83..f805fd1ae 100644
--- a/src/__tests__/hooks/tweets/useTweetLikers.test.tsx
+++ b/src/__tests__/hooks/tweets/useTweetLikers.test.tsx
@@ -38,15 +38,29 @@ describe('useTweetLikers', () => {
username: 'liker1',
displayName: 'Liker One',
avatarUrl: 'avatar1.jpg',
- isFollowing: true,
- isFollower: false,
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
{
username: 'liker2',
displayName: 'Liker Two',
avatarUrl: null,
- isFollowing: false,
- isFollower: true,
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
],
pagination: {
@@ -92,8 +106,15 @@ describe('useTweetLikers', () => {
username: 'liker1',
displayName: 'Liker One',
avatarUrl: 'avatar.jpg',
- isFollowing: false,
- isFollower: false,
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
],
pagination: {
@@ -109,8 +130,15 @@ describe('useTweetLikers', () => {
username: 'liker2',
displayName: 'Liker Two',
avatarUrl: 'avatar2.jpg',
- isFollowing: true,
- isFollower: true,
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: true,
+ follower: true,
+ },
},
],
pagination: {
diff --git a/src/__tests__/hooks/tweets/useTweetQuotes.test.tsx b/src/__tests__/hooks/tweets/useTweetQuotes.test.tsx
index 57f3b6d87..101ca93eb 100644
--- a/src/__tests__/hooks/tweets/useTweetQuotes.test.tsx
+++ b/src/__tests__/hooks/tweets/useTweetQuotes.test.tsx
@@ -40,6 +40,15 @@ describe('useTweetQuotes', () => {
username: 'quoter1',
displayName: 'Quoter One',
avatarUrl: 'avatar1.jpg',
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
content: 'This is a great take!',
createdAt: '2025-06-15T10:30:00Z',
@@ -100,6 +109,15 @@ describe('useTweetQuotes', () => {
username: 'quoter1',
displayName: 'Quoter One',
avatarUrl: 'avatar.jpg',
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
content: 'First quote',
createdAt: '2025-06-15T10:30:00Z',
@@ -130,6 +148,15 @@ describe('useTweetQuotes', () => {
username: 'quoter2',
displayName: 'Quoter Two',
avatarUrl: null,
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
content: 'Second quote with media',
createdAt: '2025-06-16T14:00:00Z',
diff --git a/src/__tests__/hooks/tweets/useTweetReplies.test.tsx b/src/__tests__/hooks/tweets/useTweetReplies.test.tsx
new file mode 100644
index 000000000..576251c39
--- /dev/null
+++ b/src/__tests__/hooks/tweets/useTweetReplies.test.tsx
@@ -0,0 +1,99 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react-native';
+
+import { useTweetReplies } from '@/hooks/tweets/useTweetReplies';
+import { getTweetCache } from '@/libs/tweetCache';
+import { getTweetReplies } from '@/services/tweets';
+
+// Mock dependencies
+jest.mock('@/services/tweets', () => ({
+ getTweetReplies: jest.fn(),
+}));
+
+jest.mock('@/libs/tweetCache', () => ({
+ getTweetCache: jest.fn(() => ({
+ setTweets: jest.fn(),
+ })),
+}));
+
+describe('useTweetReplies', () => {
+ let queryClient: QueryClient;
+ const mockTweetCache = {
+ setTweets: jest.fn(),
+ };
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+ jest.clearAllMocks();
+ (getTweetCache as jest.Mock).mockReturnValue(mockTweetCache);
+ });
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ it('fetches and caches first page of replies', async () => {
+ const mockReplies = [{ id: 'r1', content: 'reply 1' }];
+ (getTweetReplies as jest.Mock).mockResolvedValue({
+ data: mockReplies,
+ pagination: { hasNextPage: true, nextCursor: 'next-cursor' },
+ });
+
+ const { result } = renderHook(() => useTweetReplies('123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data?.pages[0].data).toEqual(mockReplies);
+ expect(mockTweetCache.setTweets).toHaveBeenCalledWith(mockReplies);
+ });
+
+ it('fetches next page with cursor', async () => {
+ // Setup mocks for sequential calls
+ (getTweetReplies as jest.Mock)
+ .mockResolvedValueOnce({
+ data: [{ id: 'r1', content: 'reply 1' }],
+ pagination: { hasNextPage: true, nextCursor: 'page2' },
+ })
+ .mockResolvedValueOnce({
+ data: [{ id: 'r2', content: 'reply 2' }],
+ pagination: { hasNextPage: false },
+ });
+
+ const { result } = renderHook(() => useTweetReplies('123'), { wrapper });
+
+ // Wait for first page
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.pages).toHaveLength(1);
+
+ // Trigger next page
+ await result.current.fetchNextPage();
+
+ // Wait for the update
+ await waitFor(() => expect(result.current.data?.pages).toHaveLength(2));
+
+ // Verify calls
+ expect(getTweetReplies).toHaveBeenCalledTimes(2);
+ expect(getTweetReplies).toHaveBeenLastCalledWith(
+ '123',
+ expect.objectContaining({ cursor: 'page2' })
+ );
+ });
+
+ it('handles empty response gracefully', async () => {
+ (getTweetReplies as jest.Mock).mockResolvedValue({
+ data: [],
+ pagination: { hasNextPage: false },
+ });
+
+ const { result } = renderHook(() => useTweetReplies('123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.pages[0].data).toEqual([]);
+ });
+});
diff --git a/src/__tests__/hooks/tweets/useTweetRetweeters.test.tsx b/src/__tests__/hooks/tweets/useTweetRetweeters.test.tsx
index 913908a1e..d5c8522bf 100644
--- a/src/__tests__/hooks/tweets/useTweetRetweeters.test.tsx
+++ b/src/__tests__/hooks/tweets/useTweetRetweeters.test.tsx
@@ -38,24 +38,30 @@ describe('useTweetRetweeters', () => {
username: 'retweeter1',
displayName: 'Retweeter One',
avatarUrl: 'avatar1.jpg',
- isFollowing: true,
- isFollower: false,
- bio: {
- text: 'Software developer and tech enthusiast',
- bioEntities: null,
+ bioEntities: null,
+ bio: null,
+
+ relationship: {
+ following: true,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
},
- isBlocked: false,
- isMuted: false,
},
{
username: 'retweeter2',
displayName: 'Retweeter Two',
avatarUrl: null,
- isFollowing: false,
- isFollower: true,
bio: null,
- isBlocked: false,
- isMuted: false,
+ bioEntities: null,
+ relationship: {
+ following: false,
+ follower: true,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
},
],
pagination: {
@@ -101,17 +107,15 @@ describe('useTweetRetweeters', () => {
username: 'retweeter1',
displayName: 'Retweeter One',
avatarUrl: 'avatar.jpg',
- isFollowing: false,
- isFollower: false,
- bio: {
- text: 'Bio text here',
- bioEntities: {
- mentions: [{ username: 'user', startPosition: 5 }],
- hashtags: null,
- },
+ bioEntities: null,
+ bio: null,
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
},
- isBlocked: false,
- isMuted: false,
},
],
pagination: {
@@ -127,17 +131,15 @@ describe('useTweetRetweeters', () => {
username: 'retweeter2',
displayName: 'Retweeter Two',
avatarUrl: 'avatar2.jpg',
- isFollowing: true,
- isFollower: true,
- bio: {
- text: 'Another bio',
- bioEntities: {
- mentions: null,
- hashtags: [{ hashtag: 'tech', startPosition: 0 }],
- },
+ bioEntities: null,
+ bio: null,
+ relationship: {
+ follwer: true,
+ following: true,
+ muted: true,
+ blocking: false,
+ blockedBy: false,
},
- isBlocked: false,
- isMuted: true,
},
],
pagination: {
diff --git a/src/__tests__/hooks/useChatSocket.test.tsx b/src/__tests__/hooks/useChatSocket.test.tsx
new file mode 100644
index 000000000..3be9f192d
--- /dev/null
+++ b/src/__tests__/hooks/useChatSocket.test.tsx
@@ -0,0 +1,401 @@
+/* eslint-disable react/display-name */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react-native';
+
+import { useChatSocket } from '@/hooks/useChatSocket';
+import * as socketService from '@/services/socket';
+
+jest.mock('@/stores/userStore', () => ({
+ useUserStore: (selector: any) =>
+ selector({
+ user: { username: 'testuser', displayName: 'Test User', avatarUrl: 'avatar.jpg' },
+ }),
+}));
+
+jest.mock('@/stores/sessionStore', () => ({
+ useSessionStore: {
+ getState: () => ({ getAccessToken: () => 'test-token' }),
+ },
+}));
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+describe('useChatSocket', () => {
+ let mockSocket: any;
+ let eventHandlers: Record;
+
+ beforeEach(() => {
+ eventHandlers = {};
+ mockSocket = {
+ connected: true,
+ on: jest.fn((event: string, handler: Function) => {
+ eventHandlers[event] = handler;
+ }),
+ off: jest.fn((event: string) => {
+ delete eventHandlers[event];
+ }),
+ emit: jest.fn(),
+ };
+
+ jest.spyOn(socketService, 'initSocket').mockReturnValue(mockSocket);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('initializes socket and joins conversation on mount', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+
+ renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ expect(socketService.initSocket).toHaveBeenCalledWith('test-token');
+ });
+
+ it('registers all socket event handlers', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+
+ renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ expect(mockSocket.on).toHaveBeenCalledWith('message_received', expect.any(Function));
+ expect(mockSocket.on).toHaveBeenCalledWith('conversation_seen_update', expect.any(Function));
+ expect(mockSocket.on).toHaveBeenCalledWith('reaction_received', expect.any(Function));
+ expect(mockSocket.on).toHaveBeenCalledWith('user_typing', expect.any(Function));
+ expect(mockSocket.on).toHaveBeenCalledWith('user_typing_stop', expect.any(Function));
+ });
+
+ it('calls markSeen when receiving message from other user', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+
+ renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ eventHandlers.message_received({
+ conversationId: 'conv1',
+ message: {
+ id: 'msg1',
+ body: 'Hello',
+ sender: { username: 'other', displayName: 'Other', avatarUrl: null },
+ createdAt: new Date().toISOString(),
+ },
+ });
+
+ expect(markSeen).toHaveBeenCalledWith('msg1');
+ });
+
+ it('does not call markSeen for own messages', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+
+ renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ eventHandlers.message_received({
+ conversationId: 'conv1',
+ message: {
+ id: 'msg1',
+ body: 'My message',
+ sender: { username: 'testuser', displayName: 'Test User', avatarUrl: 'avatar.jpg' },
+ createdAt: new Date().toISOString(),
+ },
+ });
+
+ expect(markSeen).not.toHaveBeenCalled();
+ });
+
+ it('calls onUserTyping when user_typing event received', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+ const onUserTyping = jest.fn();
+
+ renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ onUserTyping,
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ eventHandlers.user_typing({
+ conversationId: 'conv1',
+ username: 'other',
+ });
+
+ expect(onUserTyping).toHaveBeenCalledWith(true);
+ });
+
+ it('calls onUserTyping with false when user_typing_stop event received', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+ const onUserTyping = jest.fn();
+
+ renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ onUserTyping,
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ eventHandlers.user_typing_stop({
+ conversationId: 'conv1',
+ username: 'other',
+ });
+
+ expect(onUserTyping).toHaveBeenCalledWith(false);
+ });
+
+ it('ignores typing events from current user', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+ const onUserTyping = jest.fn();
+
+ renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ onUserTyping,
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ eventHandlers.user_typing({
+ conversationId: 'conv1',
+ username: 'testuser',
+ });
+
+ expect(onUserTyping).not.toHaveBeenCalled();
+ });
+
+ it('ignores events from different conversations', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+ const onUserTyping = jest.fn();
+
+ renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ onUserTyping,
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ eventHandlers.user_typing({
+ conversationId: 'conv2',
+ username: 'other',
+ });
+
+ eventHandlers.message_received({
+ conversationId: 'conv2',
+ message: {
+ id: 'msg1',
+ body: 'Wrong conv',
+ sender: { username: 'other', displayName: 'Other', avatarUrl: null },
+ createdAt: new Date().toISOString(),
+ },
+ });
+
+ expect(onUserTyping).not.toHaveBeenCalled();
+ expect(markSeen).not.toHaveBeenCalled();
+ });
+
+ it('updates conversation seen status on conversation_seen_update', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+
+ const queryClient = new QueryClient();
+ queryClient.setQueryData(['dm', 'conversations', 'conv1', 'messages'], {
+ pages: [
+ {
+ data: {
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ messages: [
+ {
+ id: 'msg1',
+ content: 'Test',
+ isMine: true,
+ createdAt: new Date().toISOString(),
+ },
+ ],
+ },
+ pagination: { hasNextPage: false, nextCursor: null },
+ },
+ ],
+ pageParams: [null],
+ });
+
+ renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ }
+ );
+
+ eventHandlers.conversation_seen_update({
+ conversationId: 'conv1',
+ lastSeenMessageId: 'msg1',
+ username: 'testuser',
+ });
+
+ expect(setLastSeenMessageId).toHaveBeenCalledWith('msg1');
+ });
+
+ it('cleans up socket listeners on unmount', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+
+ const { unmount } = renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ unmount();
+
+ expect(mockSocket.off).toHaveBeenCalledWith('message_received', expect.any(Function));
+ expect(mockSocket.off).toHaveBeenCalledWith('conversation_seen_update', expect.any(Function));
+ expect(mockSocket.off).toHaveBeenCalledWith('reaction_received', expect.any(Function));
+ expect(mockSocket.off).toHaveBeenCalledWith('user_typing', expect.any(Function));
+ expect(mockSocket.off).toHaveBeenCalledWith('user_typing_stop', expect.any(Function));
+ });
+
+ it('handles onUserTyping being undefined', () => {
+ const markSeen = jest.fn();
+ const setLastSeenMessageId = jest.fn();
+ const initiallyMarkedRef = { current: null };
+
+ renderHook(
+ () =>
+ useChatSocket({
+ conversationId: 'conv1',
+ markSeen,
+ lastSeenMessageId: null,
+ setLastSeenMessageId,
+ otherParticipantLastSeenMessageId: null,
+ initiallyMarkedOwnMessageIdRef: initiallyMarkedRef,
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ onUserTyping: undefined,
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ // Should not throw error
+ eventHandlers.user_typing({
+ conversationId: 'conv1',
+ username: 'other',
+ });
+ });
+});
diff --git a/src/__tests__/hooks/useDeleteMessage.test.tsx b/src/__tests__/hooks/useDeleteMessage.test.tsx
new file mode 100644
index 000000000..9fd4e6a26
--- /dev/null
+++ b/src/__tests__/hooks/useDeleteMessage.test.tsx
@@ -0,0 +1,152 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { Alert } from 'react-native';
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+
+import { useDeleteMessage } from '@/hooks/useDeleteMessage';
+import { deleteMessage, dmKeys } from '@/services/dm';
+
+import type { Message } from '@/types/dm';
+
+jest.mock('@/services/dm', () => ({
+ deleteMessage: jest.fn(),
+ dmKeys: {
+ messages: (id: string) => ['messages', id],
+ },
+}));
+
+// Mock Alert.alert
+const mockAlert = jest.fn();
+jest.spyOn(Alert, 'alert').mockImplementation(mockAlert);
+
+// Wrapper for query client
+const createWrapper = (client: QueryClient) => {
+ const Wrapper = ({ children }: any) => (
+ {children}
+ );
+ Wrapper.displayName = 'QueryClientTestWrapper';
+ return Wrapper;
+};
+
+describe('useDeleteMessage', () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+
+ jest.clearAllMocks();
+ });
+
+ it('calls deleteMessage API and removes message from cache on success', async () => {
+ const conversationId = 'conv123';
+ const messageId = 'msg1';
+
+ (deleteMessage as jest.Mock).mockResolvedValue({ success: true });
+
+ // Seed cache
+ const initialCache = {
+ pages: [
+ {
+ data: {
+ messages: [
+ { id: 'msg1', text: 'Hello' },
+ { id: 'msg2', text: 'World' },
+ ] as unknown as Message[],
+ },
+ },
+ ],
+ };
+
+ queryClient.setQueryData(dmKeys.messages(conversationId), initialCache);
+
+ const { result } = renderHook(() => useDeleteMessage({ conversationId }), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ await act(async () => {
+ result.current.deleteMessage(messageId);
+ });
+
+ // API called
+ expect(deleteMessage).toHaveBeenCalledWith(conversationId, messageId);
+
+ // Updated cache safely typed
+ const updated = queryClient.getQueryData<{ pages: { data: { messages: Message[] } }[] }>(
+ dmKeys.messages(conversationId)
+ );
+
+ expect(updated?.pages[0].data.messages).toEqual([{ id: 'msg2', text: 'World' }]);
+ });
+
+ it('shows an error alert on API error', async () => {
+ const conversationId = 'conv123';
+
+ (deleteMessage as jest.Mock).mockRejectedValue({
+ response: { data: { error: { message: 'Unable to delete.' } } },
+ });
+
+ const { result } = renderHook(() => useDeleteMessage({ conversationId }), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ await act(async () => {
+ result.current.deleteMessage('msg1');
+ });
+
+ expect(mockAlert).toHaveBeenCalledWith('Error', 'Unable to delete.');
+ });
+
+ it('shows default error if error message is missing', async () => {
+ const conversationId = 'conv123';
+
+ (deleteMessage as jest.Mock).mockRejectedValue({});
+
+ const { result } = renderHook(() => useDeleteMessage({ conversationId }), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ await act(async () => {
+ result.current.deleteMessage('msg1');
+ });
+
+ expect(mockAlert).toHaveBeenCalledWith('Error', 'Failed to delete message. Please try again.');
+ });
+
+ it('exposes isDeleting during the mutation lifecycle', async () => {
+ const conversationId = 'conv123';
+
+ let resolvePromise: any;
+ const mockPromise = new Promise((res) => (resolvePromise = res));
+
+ (deleteMessage as jest.Mock).mockReturnValue(mockPromise);
+
+ const { result } = renderHook(() => useDeleteMessage({ conversationId }), {
+ wrapper: createWrapper(queryClient),
+ });
+
+ // Trigger mutation
+ act(() => {
+ result.current.deleteMessage('msg1');
+ });
+
+ // Wait for isDeleting = true
+ await waitFor(() => {
+ expect(result.current.isDeleting).toBe(true);
+ });
+
+ // Resolve mutation
+ await act(async () => {
+ resolvePromise({ success: true });
+ });
+
+ // Wait for isDeleting = false
+ await waitFor(() => {
+ expect(result.current.isDeleting).toBe(false);
+ });
+ });
+});
diff --git a/src/__tests__/hooks/useDmSse.test.tsx b/src/__tests__/hooks/useDmSse.test.tsx
deleted file mode 100644
index e6711db4d..000000000
--- a/src/__tests__/hooks/useDmSse.test.tsx
+++ /dev/null
@@ -1,385 +0,0 @@
-import { ReactNode } from 'react';
-
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { act, renderHook, waitFor } from '@testing-library/react-native';
-
-import { useDmSse } from '@/hooks/useDmSse';
-import { navigationRef } from '@/navigation/navigationRef';
-import { dmKeys, getUnseenConversationsCount } from '@/services/dm';
-import { useDmStore } from '@/stores/dmStore';
-import { useSessionStore } from '@/stores/sessionStore';
-import { MESSAGES } from '@/utils/navigation/routeNames';
-
-import type { Conversation } from '@/types/dm';
-
-type MockEventSourceInstance = {
- addEventListener: jest.Mock;
- removeAllEventListeners: jest.Mock;
- close: jest.Mock;
-};
-
-jest.mock('react-native-sse', () => {
- const listeners: Record = {};
- let lastInstance: MockEventSourceInstance | null = null;
- let shouldThrow = false;
-
- class MockEventSource {
- constructor() {
- if (shouldThrow) {
- shouldThrow = false;
- throw new Error('boom');
- }
- listeners['dm.unseen_conversations_count'] = jest.fn();
- listeners['dm.new_message'] = jest.fn();
- listeners.error = jest.fn();
- this.addEventListener = jest.fn((event: string, handler: jest.Mock) => {
- listeners[event] = handler;
- });
- this.removeAllEventListeners = jest.fn();
- this.close = jest.fn();
- lastInstance = this as unknown as MockEventSourceInstance;
- }
- addEventListener: jest.Mock;
- removeAllEventListeners: jest.Mock;
- close: jest.Mock;
- }
-
- return {
- __esModule: true,
- default: MockEventSource,
- __listeners: listeners,
- __getLastInstance: () => lastInstance,
- __setShouldThrow: (value: boolean) => {
- shouldThrow = value;
- },
- };
-});
-
-jest.mock('@/stores/dmStore', () => ({
- useDmStore: jest.fn(),
-}));
-
-jest.mock('@/stores/sessionStore', () => ({
- useSessionStore: jest.fn(),
-}));
-
-jest.mock('@/navigation/navigationRef', () => ({
- navigationRef: {
- getCurrentRoute: jest.fn(),
- },
-}));
-
-jest.mock('@/services/dm', () => {
- const actual = jest.requireActual('@/services/dm');
- return {
- ...actual,
- getUnseenConversationsCount: jest.fn(),
- };
-});
-
-const mockGetUnseenConversationsCount = getUnseenConversationsCount as jest.MockedFunction<
- typeof getUnseenConversationsCount
->;
-const mockUseDmStore = useDmStore as unknown as jest.Mock;
-const mockUseSessionStore = useSessionStore as unknown as jest.Mock;
-const listenersModule = jest.requireMock('react-native-sse') as {
- __listeners: Record void>;
- __getLastInstance: () => MockEventSourceInstance | null;
- __setShouldThrow: (value: boolean) => void;
-};
-const navigationRefMock = navigationRef as unknown as {
- getCurrentRoute: jest.Mock;
-};
-
-let accessToken = 'token-123';
-
-describe('useDmSse', () => {
- const createWrapper = () => {
- const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false, gcTime: Infinity } },
- });
-
- const Wrapper = ({ children }: { children: ReactNode }) => (
- {children}
- );
- Wrapper.displayName = 'QueryClientWrapper';
-
- return { Wrapper, queryClient };
- };
-
- const sampleConversation: Conversation = {
- id: 'conversation-1',
- participant: { username: 'alice', displayName: 'Alice', avatarUrl: null },
- lastMessage: {
- content: 'Old',
- senderUsername: 'alice',
- sentAt: '2024-01-01T00:00:00.000Z',
- },
- isMuted: false,
- };
-
- let setUnseenCount: jest.Mock;
- let setActiveConversationId: jest.Mock;
- let activeConversationId: string | null;
-
- beforeEach(() => {
- jest.clearAllMocks();
- setUnseenCount = jest.fn();
- setActiveConversationId = jest.fn((value: string | null) => {
- activeConversationId = value;
- });
- activeConversationId = null;
- mockUseDmStore.mockImplementation(
- (
- selector: (state: {
- unseenCount: number;
- setUnseenCount: typeof setUnseenCount;
- activeConversationId: typeof activeConversationId;
- setActiveConversationId: typeof setActiveConversationId;
- }) => unknown
- ) =>
- selector({
- unseenCount: 0,
- setUnseenCount,
- activeConversationId,
- setActiveConversationId,
- })
- );
- accessToken = 'token-123';
- mockUseSessionStore.mockImplementation(
- (selector: (state: { getAccessToken: () => string }) => string) =>
- selector({ getAccessToken: () => accessToken })
- );
- navigationRefMock.getCurrentRoute.mockReturnValue({ name: MESSAGES.ALL_MESSAGES });
- mockGetUnseenConversationsCount.mockResolvedValue({
- success: true,
- data: { count: 4 },
- message: 'ok',
- });
- });
-
- it('subscribes to events and updates caches/unseen counts', async () => {
- const { Wrapper, queryClient } = createWrapper();
- queryClient.setQueryData(dmKeys.conversations(), [sampleConversation]);
- const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
-
- renderHook((props: { accountId: string }) => useDmSse(props.accountId), {
- wrapper: Wrapper,
- initialProps: { accountId: 'acc-1' },
- });
-
- await act(async () => {
- listenersModule.__listeners['dm.unseen_conversations_count']?.({
- data: JSON.stringify({ count: 3 }),
- });
- });
-
- expect(setUnseenCount).toHaveBeenCalledWith(3);
-
- const newMessagePayload = {
- conversationId: 'conversation-1',
- messageId: 'm-1',
- sender: { id: 's-1', username: 'alice', displayName: 'Alice', avatarUrl: null },
- bodySnippet: 'Latest hello',
- createdAt: '2024-02-01T00:00:00.000Z',
- hasMedia: true,
- };
-
- await act(async () => {
- listenersModule.__listeners['dm.new_message']?.({
- data: JSON.stringify(newMessagePayload),
- });
- });
-
- await waitFor(() => {
- const updated = queryClient.getQueryData(dmKeys.conversations()) as Conversation[];
- expect(updated[0].lastMessage?.content).toBe('Latest hello');
- expect(updated[0].lastMessage?.hasMedia).toBe(true);
- });
-
- await act(async () => {
- listenersModule.__listeners['dm.new_message']?.({
- data: JSON.stringify({ ...newMessagePayload, conversationId: 'missing' }),
- });
- });
-
- expect(invalidateSpy).toHaveBeenCalledTimes(1);
- });
-
- it('marks new messages as seen when their conversation is open', async () => {
- activeConversationId = 'conversation-1';
- const { Wrapper, queryClient } = createWrapper();
- queryClient.setQueryData(dmKeys.conversations(), [sampleConversation]);
-
- renderHook(() => useDmSse('account-1'), { wrapper: Wrapper });
-
- const newMessagePayload = {
- conversationId: 'conversation-1',
- messageId: 'm-open',
- sender: { id: 's-1', username: 'alice', displayName: 'Alice', avatarUrl: null },
- bodySnippet: 'Hi while open',
- createdAt: '2024-02-01T00:00:00.000Z',
- hasMedia: false,
- };
-
- await act(async () => {
- listenersModule.__listeners['dm.new_message']?.({
- data: JSON.stringify(newMessagePayload),
- });
- });
-
- await waitFor(() => {
- const updated = queryClient.getQueryData(dmKeys.conversations()) as Conversation[];
- expect(updated[0].lastMessage?.seen).toBe(true);
- });
- });
-
- it('cleans up source and resets unseen count when account deactivates', () => {
- const { Wrapper } = createWrapper();
-
- const { rerender, unmount } = renderHook(
- (props: { accountId: string | null }) => useDmSse(props.accountId),
- {
- wrapper: Wrapper,
- initialProps: { accountId: 'acc-1' },
- }
- );
-
- rerender({ accountId: null });
- expect(setUnseenCount).toHaveBeenCalledWith(0);
-
- unmount();
- const instance = listenersModule.__getLastInstance();
- expect(instance?.removeAllEventListeners).toHaveBeenCalled();
- expect(instance?.close).toHaveBeenCalled();
- });
-
- it('updates paginated and response caches, and surfaces SSE errors', async () => {
- const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
- const { Wrapper, queryClient } = createWrapper();
-
- const pagedConversation: Conversation = { ...sampleConversation, id: 'paged' };
- queryClient.setQueryData(dmKeys.conversations(), {
- pages: [{ data: [pagedConversation] }],
- pageParams: [undefined],
- });
-
- renderHook(() => useDmSse('account-1'), { wrapper: Wrapper });
-
- const basePayload = {
- conversationId: 'paged',
- messageId: 'm-2',
- sender: { id: 's-2', username: 'alice', displayName: 'Alice', avatarUrl: null },
- bodySnippet: 'Paged hello',
- createdAt: '2024-03-01T00:00:00.000Z',
- hasMedia: false,
- };
-
- await act(async () => {
- listenersModule.__listeners['dm.new_message']?.({ data: JSON.stringify(basePayload) });
- });
-
- await waitFor(() => {
- const updated = queryClient.getQueryData(dmKeys.conversations()) as {
- pages: { data: Conversation[] }[];
- };
- expect(updated.pages[0].data[0].lastMessage?.content).toBe('Paged hello');
- });
-
- const responseConversation: Conversation = { ...sampleConversation, id: 'success' };
- queryClient.setQueryData(dmKeys.conversations(), {
- data: [responseConversation],
- success: true,
- message: 'ok',
- });
-
- await act(async () => {
- listenersModule.__listeners['dm.new_message']?.({
- data: JSON.stringify({ ...basePayload, conversationId: 'success', bodySnippet: 'Updated' }),
- });
- });
-
- await waitFor(() => {
- const updated = queryClient.getQueryData(dmKeys.conversations()) as { data: Conversation[] };
- expect(updated.data[0].lastMessage?.content).toBe('Updated');
- });
-
- await act(async () => {
- listenersModule.__listeners.error?.({ type: 'error' } as {
- data?: string;
- });
- });
-
- listenersModule.__setShouldThrow(true);
- accessToken = 'token-999';
- renderHook(() => useDmSse('account-1'), { wrapper: Wrapper });
- await waitFor(() => {
- expect(warnSpy).toHaveBeenCalledWith(
- '[SSE] Failed to initialize connection:',
- expect.anything()
- );
- });
-
- warnSpy.mockRestore();
- });
-
- it('leaves unsupported cache shapes untouched when processing events', async () => {
- const { Wrapper, queryClient } = createWrapper();
- queryClient.setQueryData(dmKeys.conversations(), 'a');
-
- renderHook(() => useDmSse('account-1'), { wrapper: Wrapper });
-
- const payload = {
- conversationId: 'b',
- messageId: 'm-b',
- sender: { id: 's-b', username: 'b', displayName: 'b', avatarUrl: null },
- bodySnippet: 'hello',
- createdAt: '2025-11-01T00:00:00.000Z',
- hasMedia: false,
- };
-
- await act(async () => {
- listenersModule.__listeners['dm.new_message']?.({ data: JSON.stringify(payload) });
- });
-
- expect(queryClient.getQueryData(dmKeys.conversations())).toBe('a');
- });
-
- it('restarts SSE connection when the token changes', async () => {
- const { Wrapper } = createWrapper();
- const { rerender } = renderHook((props: { accountId: string }) => useDmSse(props.accountId), {
- wrapper: Wrapper,
- initialProps: { accountId: 'acc-1' },
- });
-
- const firstInstance = listenersModule.__getLastInstance();
- expect(firstInstance).toBeTruthy();
-
- accessToken = 'token-456';
- rerender({ accountId: 'acc-1' });
-
- await waitFor(() => {
- expect(firstInstance?.removeAllEventListeners).toHaveBeenCalled();
- expect(firstInstance?.close).toHaveBeenCalled();
- });
- });
-
- it('restarts SSE connection when the session token changes', async () => {
- const { Wrapper } = createWrapper();
- const { rerender } = renderHook((props: { accountId: string }) => useDmSse(props.accountId), {
- wrapper: Wrapper,
- initialProps: { accountId: 'acc-1' },
- });
-
- const firstInstance = listenersModule.__getLastInstance();
- expect(firstInstance).toBeTruthy();
-
- accessToken = 'token-456';
- rerender({ accountId: 'acc-1' });
-
- await waitFor(() => {
- expect(firstInstance?.removeAllEventListeners).toHaveBeenCalled();
- expect(firstInstance?.close).toHaveBeenCalled();
- });
- });
-});
diff --git a/src/__tests__/hooks/useFeed.test.tsx b/src/__tests__/hooks/useFeed.test.tsx
index 580b436aa..392cd6c93 100644
--- a/src/__tests__/hooks/useFeed.test.tsx
+++ b/src/__tests__/hooks/useFeed.test.tsx
@@ -4,6 +4,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react-native';
import { useFeed } from '@/hooks/useFeed';
+import { ApiException } from '@/libs/api';
+import { queryKeys } from '@/libs/queryKeys';
import type { TimelineFeedResponse } from '@/services/timeline';
@@ -45,7 +47,20 @@ describe('useFeed', () => {
const mockTweets = [
{
id: '1',
- author: { username: 'user1', displayName: 'User 1', avatarUrl: '' },
+ author: {
+ username: 'user1',
+ displayName: 'User 1',
+ avatarUrl: '',
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: undefined,
+ },
+ },
content: 'Test tweet 1',
createdAt: '2025-01-01T00:00:00Z',
replyCount: 1,
@@ -61,7 +76,20 @@ describe('useFeed', () => {
},
{
id: '2',
- author: { username: 'user2', displayName: 'User 2', avatarUrl: '' },
+ author: {
+ username: 'user2',
+ displayName: 'User 2',
+ avatarUrl: '',
+ bio: null,
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: undefined,
+ },
+ },
content: 'Test tweet 2',
createdAt: '2025-01-02T00:00:00Z',
replyCount: 0,
@@ -90,9 +118,12 @@ describe('useFeed', () => {
});
const { Wrapper } = createWrapper();
- const { result } = renderHook(() => useFeed({ cacheKey: 'test-feed', fetcher: mockFetcher }), {
- wrapper: Wrapper,
- });
+ const { result } = renderHook(
+ () => useFeed({ queryKey: queryKeys.timeline.forYou('testuser'), fetcher: mockFetcher }),
+ {
+ wrapper: Wrapper,
+ }
+ );
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -116,9 +147,12 @@ describe('useFeed', () => {
});
const { Wrapper } = createWrapper();
- const { result } = renderHook(() => useFeed({ cacheKey: 'test-feed', fetcher: mockFetcher }), {
- wrapper: Wrapper,
- });
+ const { result } = renderHook(
+ () => useFeed({ queryKey: queryKeys.timeline.forYou('testuser'), fetcher: mockFetcher }),
+ {
+ wrapper: Wrapper,
+ }
+ );
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -134,9 +168,12 @@ describe('useFeed', () => {
mockFetcher.mockRejectedValueOnce(new Error('network'));
const { Wrapper } = createWrapper();
- const { result } = renderHook(() => useFeed({ cacheKey: 'test-feed', fetcher: mockFetcher }), {
- wrapper: Wrapper,
- });
+ const { result } = renderHook(
+ () => useFeed({ queryKey: queryKeys.timeline.forYou('testuser'), fetcher: mockFetcher }),
+ {
+ wrapper: Wrapper,
+ }
+ );
await waitFor(() => expect(result.current.isError).toBe(true));
expect((result.current.error as Error).message).toBe('network');
@@ -151,15 +188,46 @@ describe('useFeed', () => {
});
const { Wrapper, queryClient } = createWrapper();
- const { result } = renderHook(() => useFeed({ cacheKey: 'test-feed', fetcher: mockFetcher }), {
- wrapper: Wrapper,
- });
+ const { result } = renderHook(
+ () => useFeed({ queryKey: queryKeys.timeline.forYou('testuser'), fetcher: mockFetcher }),
+ {
+ wrapper: Wrapper,
+ }
+ );
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const queries = queryClient.getQueryCache().getAll();
- const cacheEntry = queries.find((query) => query.queryKey[0] === 'test-feed');
+ const expectedKey = queryKeys.timeline.forYou('testuser');
+ const cacheEntry = queries.find(
+ (query) => JSON.stringify(query.queryKey) === JSON.stringify(expectedKey)
+ );
+
+ expect(cacheEntry?.queryKey).toEqual(expectedKey);
+ });
+
+ it('resets and retries from the beginning on 410 Gone error', async () => {
+ const goneError = new ApiException(410, null, 'Cursor expired');
+ mockFetcher.mockRejectedValueOnce(goneError).mockResolvedValueOnce({
+ success: true,
+ message: 'ok',
+ data: mockTweets,
+ pagination: { cursor: null, nextCursor: null, hasNextPage: false },
+ });
+
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(
+ () => useFeed({ queryKey: queryKeys.timeline.forYou('testuser'), fetcher: mockFetcher }),
+ {
+ wrapper: Wrapper,
+ }
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
- expect(cacheEntry?.queryKey).toEqual(['test-feed', 'testuser']);
+ expect(mockFetcher).toHaveBeenCalledTimes(2);
+ expect(mockFetcher).toHaveBeenNthCalledWith(1, { cursor: null, limit: 25 });
+ expect(mockFetcher).toHaveBeenNthCalledWith(2, { cursor: null, limit: 25 });
+ expect(result.current.data?.pages[0].data).toEqual(mockTweets);
});
});
diff --git a/src/__tests__/hooks/useMediaLibrary.test.ts b/src/__tests__/hooks/useMediaLibrary.test.ts
index c89d7c4e0..efa02327b 100644
--- a/src/__tests__/hooks/useMediaLibrary.test.ts
+++ b/src/__tests__/hooks/useMediaLibrary.test.ts
@@ -78,10 +78,11 @@ describe('useMediaLibrary', () => {
expect(result.current.loading).toBe(false);
});
- it('shows alert when permission is denied for albums', async () => {
+ it('logs warning when permission is denied for albums', async () => {
const initialPermissionResponse = { granted: false, status: 'undetermined' as const };
const deniedPermissionResponse = { granted: false, status: 'denied' as const };
const mockRequestPermission = jest.fn().mockResolvedValue(deniedPermissionResponse);
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
mockUsePermissions.mockReturnValue([initialPermissionResponse, mockRequestPermission]);
@@ -92,9 +93,11 @@ describe('useMediaLibrary', () => {
});
expect(mockRequestPermission).toHaveBeenCalled();
- expect(mockAlert).toHaveBeenCalledWith('Permission denied');
+ expect(consoleWarnSpy).toHaveBeenCalledWith('Media library permission not granted');
expect(result.current.albums).toBeNull();
expect(result.current.loading).toBe(false);
+
+ consoleWarnSpy.mockRestore();
});
it('handles fetchAlbums error gracefully', async () => {
diff --git a/src/__tests__/hooks/useMessageReaction.test.tsx b/src/__tests__/hooks/useMessageReaction.test.tsx
new file mode 100644
index 000000000..912072ccd
--- /dev/null
+++ b/src/__tests__/hooks/useMessageReaction.test.tsx
@@ -0,0 +1,180 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, renderHook } from '@testing-library/react-native';
+
+import { useMessageReaction } from '@/hooks/useMessageReaction';
+import { dmKeys } from '@/services/dm';
+import { getSocket } from '@/services/socket';
+import { useUserStore } from '@/stores/userStore';
+
+jest.mock('@/stores/userStore');
+jest.mock('@/services/socket', () => ({
+ getSocket: jest.fn(),
+}));
+
+const mockEmit = jest.fn();
+(getSocket as jest.Mock).mockReturnValue({
+ emit: mockEmit,
+});
+
+const mockUser = { username: 'alice' };
+
+(useUserStore as unknown as jest.Mock).mockImplementation((selector) =>
+ selector({ user: mockUser })
+);
+
+const buildWrapper = (client: QueryClient) =>
+ function Wrapper({ children }: any) {
+ return {children} ;
+ };
+
+describe('useMessageReaction', () => {
+ let queryClient: QueryClient;
+
+ const conversationId = 'convo1';
+ const messageId = 'msg1';
+
+ const initialMessages = {
+ pages: [
+ {
+ data: {
+ messages: [
+ {
+ id: messageId,
+ text: 'hello',
+ reactions: {
+ sender: { username: 'alice', reaction: null, reactedAt: null },
+ receiver: { username: 'bob', reaction: null, reactedAt: null },
+ },
+ },
+ ],
+ },
+ },
+ { data: { messages: [] } },
+ ],
+ };
+
+ beforeEach(() => {
+ mockEmit.mockClear();
+
+ queryClient = new QueryClient();
+ queryClient.setQueryData(
+ dmKeys.messages(conversationId),
+ JSON.parse(JSON.stringify(initialMessages)) // deep clone
+ );
+ });
+
+ it('adds a reaction when sender reacts', () => {
+ const { result } = renderHook(() => useMessageReaction({ conversationId, messageId }), {
+ wrapper: buildWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.sendReaction('❤️');
+ });
+
+ const updated = queryClient.getQueryData(dmKeys.messages(conversationId)) as any;
+
+ const msg = updated.pages[0].data.messages[0];
+ expect(msg.reactions.sender.reaction).toBe('❤️');
+ expect(msg.reactions.receiver.reaction).toBe(null);
+
+ expect(mockEmit).toHaveBeenCalledWith('send_reaction', {
+ type: 'send_reaction',
+ conversationId,
+ messageId,
+ reaction: '❤️',
+ });
+ });
+
+ it('removes reaction when sender reacts with the same emoji again', () => {
+ const modified = JSON.parse(JSON.stringify(initialMessages));
+ modified.pages[0].data.messages[0].reactions.sender.reaction = '❤️';
+
+ queryClient.setQueryData(dmKeys.messages(conversationId), modified);
+
+ const { result } = renderHook(() => useMessageReaction({ conversationId, messageId }), {
+ wrapper: buildWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.sendReaction('❤️');
+ });
+
+ const updated = queryClient.getQueryData(dmKeys.messages(conversationId)) as any;
+
+ const msg = updated.pages[0].data.messages[0];
+ expect(msg.reactions.sender.reaction).toBe(null);
+ });
+
+ it('applies reaction to the receiver when current user is receiver', () => {
+ // Make current user = bob
+ (useUserStore as unknown as jest.Mock).mockImplementation((selector) =>
+ selector({ user: { username: 'bob' } })
+ );
+
+ const { result } = renderHook(() => useMessageReaction({ conversationId, messageId }), {
+ wrapper: buildWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.sendReaction('😂');
+ });
+
+ const updated = queryClient.getQueryData(dmKeys.messages(conversationId)) as any;
+ const msg = updated.pages[0].data.messages[0];
+
+ expect(msg.reactions.receiver.reaction).toBe('😂');
+ expect(msg.reactions.sender.reaction).toBe(null);
+ });
+
+ it('invalidates query on error', () => {
+ const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
+
+ (queryClient.setQueryData as any) = jest.fn(() => {
+ throw new Error('fail');
+ });
+
+ const { result } = renderHook(() => useMessageReaction({ conversationId, messageId }), {
+ wrapper: buildWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.sendReaction('🔥');
+ });
+
+ expect(invalidateSpy).toHaveBeenCalledWith({
+ queryKey: dmKeys.messages(conversationId),
+ });
+ });
+
+ it('does nothing if message is not found', () => {
+ const noMatch = {
+ pages: [
+ {
+ data: {
+ messages: [
+ { id: 'other', reactions: initialMessages.pages[0].data.messages[0].reactions },
+ ],
+ },
+ },
+ ],
+ };
+
+ queryClient.setQueryData(dmKeys.messages(conversationId), noMatch);
+
+ const { result } = renderHook(() => useMessageReaction({ conversationId, messageId }), {
+ wrapper: buildWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.sendReaction('🔥');
+ });
+
+ const updated = queryClient.getQueryData(dmKeys.messages(conversationId)) as any;
+ const msg = updated.pages[0].data.messages[0];
+
+ expect(msg.id).toBe('other');
+ expect(mockEmit).toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/hooks/useNotifications.test.tsx b/src/__tests__/hooks/useNotifications.test.tsx
index a02e22f03..41b7bbbad 100644
--- a/src/__tests__/hooks/useNotifications.test.tsx
+++ b/src/__tests__/hooks/useNotifications.test.tsx
@@ -50,7 +50,13 @@ describe('useNotifications', () => {
username: 'follower1',
displayName: 'Follower One',
avatarUrl: null,
- isFollowing: false,
+ relationship: {
+ following: false,
+ follower: null,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
],
totalCount: 1,
diff --git a/src/__tests__/hooks/useNotificationsSse.test.tsx b/src/__tests__/hooks/useNotificationsSse.test.tsx
deleted file mode 100644
index 1550973fb..000000000
--- a/src/__tests__/hooks/useNotificationsSse.test.tsx
+++ /dev/null
@@ -1,284 +0,0 @@
-import { ReactNode } from 'react';
-
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { act, renderHook, waitFor } from '@testing-library/react-native';
-
-import { useNotificationsSse } from '@/hooks/notifications/useNotificationsSse';
-import { useSessionStore } from '@/stores/sessionStore';
-
-type MockEventSourceInstance = {
- addEventListener: jest.Mock;
- removeAllEventListeners: jest.Mock;
- close: jest.Mock;
-};
-
-jest.mock('react-native-sse', () => {
- const listeners: Record = {};
- let lastInstance: MockEventSourceInstance | null = null;
-
- class MockEventSource {
- constructor() {
- this.addEventListener = jest.fn((event: string, handler: jest.Mock) => {
- listeners[event] = handler;
- });
- this.removeAllEventListeners = jest.fn();
- this.close = jest.fn();
- lastInstance = this as unknown as MockEventSourceInstance;
- }
- addEventListener: jest.Mock;
- removeAllEventListeners: jest.Mock;
- close: jest.Mock;
- }
-
- return {
- __esModule: true,
- default: MockEventSource,
- __listeners: listeners,
- __getLastInstance: () => lastInstance,
- };
-});
-
-jest.mock('@/stores/sessionStore', () => ({
- useSessionStore: jest.fn(),
-}));
-
-const mockUseSessionStore = useSessionStore as unknown as jest.Mock;
-const listenersModule = jest.requireMock('react-native-sse') as {
- __listeners: Record void>;
- __getLastInstance: () => MockEventSourceInstance | null;
-};
-
-let accessToken = 'token-123';
-
-describe('useNotificationsSse', () => {
- let queryClient: QueryClient;
-
- const createWrapper = () => {
- queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false, gcTime: Infinity } },
- });
- const Wrapper = ({ children }: { children: ReactNode }) => (
- {children}
- );
- Wrapper.displayName = 'TestWrapper';
- return Wrapper;
- };
-
- beforeEach(() => {
- jest.clearAllMocks();
- accessToken = 'token-123';
-
- mockUseSessionStore.mockImplementation((selector: (state: unknown) => unknown) => {
- const state = {
- getAccessToken: () => accessToken,
- };
- return selector ? selector(state) : state;
- });
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- it('initializes SSE connection when enabled', async () => {
- const { result } = renderHook(() => useNotificationsSse(true), {
- wrapper: createWrapper(),
- });
-
- await waitFor(() => {
- expect(result.current.isConnected).toBe(true);
- });
-
- const instance = listenersModule.__getLastInstance();
- expect(instance).not.toBeNull();
- expect(instance?.addEventListener).toHaveBeenCalledWith(
- 'notifications.count_update',
- expect.any(Function)
- );
- expect(instance?.addEventListener).toHaveBeenCalledWith(
- 'notifications.new',
- expect.any(Function)
- );
- });
-
- it('does not initialize when disabled', async () => {
- const { result } = renderHook(() => useNotificationsSse(false), {
- wrapper: createWrapper(),
- });
-
- expect(result.current.isConnected).toBe(false);
- });
-
- it('updates notification count when count_update event is received', async () => {
- renderHook(() => useNotificationsSse(true), {
- wrapper: createWrapper(),
- });
-
- await waitFor(() => {
- expect(listenersModule.__getLastInstance()).not.toBeNull();
- });
-
- // Set initial count data
- queryClient.setQueryData(['notifications', 'count'], { data: 5 });
-
- act(() => {
- listenersModule.__listeners['notifications.count_update']({
- data: JSON.stringify({ count: 10 }),
- });
- });
-
- await waitFor(() => {
- const countData = queryClient.getQueryData(['notifications', 'count']);
- expect(countData).toEqual({ data: { unseenCount: 10 } });
- });
- });
-
- it('invalidates notifications when new notification event is received', async () => {
- renderHook(() => useNotificationsSse(true), {
- wrapper: createWrapper(),
- });
-
- await waitFor(() => {
- expect(listenersModule.__getLastInstance()).not.toBeNull();
- });
-
- const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
-
- act(() => {
- listenersModule.__listeners['notifications.new']({
- data: JSON.stringify({
- id: '1',
- type: 'LIKE',
- createdAt: '2025-12-09T00:00:00Z',
- }),
- });
- });
-
- expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['notifications', 'count'] });
- expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['notifications'] });
- });
-
- it('handles malformed count_update data gracefully', async () => {
- const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
-
- renderHook(() => useNotificationsSse(true), {
- wrapper: createWrapper(),
- });
-
- await waitFor(() => {
- expect(listenersModule.__getLastInstance()).not.toBeNull();
- });
-
- act(() => {
- listenersModule.__listeners['notifications.count_update']({
- data: 'invalid json',
- });
- });
-
- await waitFor(() => {
- expect(warnSpy).toHaveBeenCalledWith(
- '[Notifications SSE] Failed to parse count_update event:',
- expect.any(Error)
- );
- });
-
- warnSpy.mockRestore();
- });
-
- it('handles malformed new notification data gracefully', async () => {
- const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
-
- renderHook(() => useNotificationsSse(true), {
- wrapper: createWrapper(),
- });
-
- await waitFor(() => {
- expect(listenersModule.__getLastInstance()).not.toBeNull();
- });
-
- act(() => {
- listenersModule.__listeners['notifications.new']({
- data: 'invalid json',
- });
- });
-
- // Since the handler checks for data first and returns early on parse error
- // we should see that it didn't crash
- warnSpy.mockRestore();
- });
-
- it('handles empty data gracefully', async () => {
- renderHook(() => useNotificationsSse(true), {
- wrapper: createWrapper(),
- });
-
- await waitFor(() => {
- expect(listenersModule.__getLastInstance()).not.toBeNull();
- });
-
- // Should not throw
- act(() => {
- listenersModule.__listeners['notifications.count_update']({ data: undefined });
- listenersModule.__listeners['notifications.new']({ data: undefined });
- });
- });
-
- it('cleans up on unmount', async () => {
- const { unmount } = renderHook(() => useNotificationsSse(true), {
- wrapper: createWrapper(),
- });
-
- await waitFor(() => {
- expect(listenersModule.__getLastInstance()).not.toBeNull();
- });
-
- const instance = listenersModule.__getLastInstance();
-
- unmount();
-
- await waitFor(() => {
- expect(instance?.close).toHaveBeenCalled();
- expect(instance?.removeAllEventListeners).toHaveBeenCalled();
- });
- });
-
- it('transitions from disabled to enabled', async () => {
- const { rerender, result } = renderHook(
- ({ enabled }: { enabled: boolean }) => useNotificationsSse(enabled),
- {
- wrapper: createWrapper(),
- initialProps: { enabled: false },
- }
- );
-
- expect(result.current.isConnected).toBe(false);
-
- rerender({ enabled: true });
-
- await waitFor(() => {
- expect(result.current.isConnected).toBe(true);
- });
- });
-
- it('transitions from enabled to disabled', async () => {
- const { rerender } = renderHook(
- ({ enabled }: { enabled: boolean }) => useNotificationsSse(enabled),
- {
- wrapper: createWrapper(),
- initialProps: { enabled: true },
- }
- );
-
- await waitFor(() => {
- expect(listenersModule.__getLastInstance()).not.toBeNull();
- });
-
- const instance = listenersModule.__getLastInstance();
-
- rerender({ enabled: false });
-
- await waitFor(() => {
- expect(instance?.close).toHaveBeenCalled();
- });
- });
-});
diff --git a/src/__tests__/hooks/useRealtimeEvents.test.tsx b/src/__tests__/hooks/useRealtimeEvents.test.tsx
new file mode 100644
index 000000000..9da0e1c0c
--- /dev/null
+++ b/src/__tests__/hooks/useRealtimeEvents.test.tsx
@@ -0,0 +1,682 @@
+import { ReactNode } from 'react';
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+
+import { useRealtimeEvents } from '@/hooks/notifications/useRealtimeEvents';
+import { dmKeys } from '@/services/dm';
+import { useDmStore } from '@/stores/dmStore';
+import { useSessionStore } from '@/stores/sessionStore';
+import { useTimelineStore } from '@/stores/timelineStore';
+
+import type { Conversation } from '@/types/dm';
+
+type MockEventSourceInstance = {
+ addEventListener: jest.Mock;
+ removeAllEventListeners: jest.Mock;
+ close: jest.Mock;
+};
+
+jest.mock('react-native-sse', () => {
+ const listeners: Record = {};
+ let lastInstance: MockEventSourceInstance | null = null;
+ let shouldThrow = false;
+
+ class MockEventSource {
+ constructor() {
+ if (shouldThrow) {
+ shouldThrow = false;
+ throw new Error('boom');
+ }
+ this.addEventListener = jest.fn((event: string, handler: jest.Mock) => {
+ listeners[event] = handler;
+ });
+ this.removeAllEventListeners = jest.fn();
+ this.close = jest.fn();
+ lastInstance = this as unknown as MockEventSourceInstance;
+ }
+ addEventListener: jest.Mock;
+ removeAllEventListeners: jest.Mock;
+ close: jest.Mock;
+ }
+
+ return {
+ __esModule: true,
+ default: MockEventSource,
+ __listeners: listeners,
+ __getLastInstance: () => lastInstance,
+ __setShouldThrow: (value: boolean) => {
+ shouldThrow = value;
+ },
+ };
+});
+
+jest.mock('@/stores/sessionStore', () => ({
+ useSessionStore: jest.fn(),
+}));
+
+jest.mock('@/stores/dmStore', () => ({
+ useDmStore: jest.fn(),
+}));
+
+jest.mock('@/stores/timelineStore', () => ({
+ useTimelineStore: jest.fn(),
+}));
+
+const mockUseSessionStore = useSessionStore as unknown as jest.Mock;
+const mockUseDmStore = useDmStore as unknown as jest.Mock;
+const mockUseTimelineStore = useTimelineStore as unknown as jest.Mock;
+const listenersModule = jest.requireMock('react-native-sse') as {
+ __listeners: Record void>;
+ __getLastInstance: () => MockEventSourceInstance | null;
+ __setShouldThrow: (value: boolean) => void;
+};
+
+let accessToken = 'token-123';
+let setUnseenCount: jest.Mock;
+let setFollowingNewTweetAuthors: jest.Mock;
+let activeConversationId: string | null = null;
+
+describe('useRealtimeEvents', () => {
+ let queryClient: QueryClient;
+
+ const createWrapper = () => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+ });
+ const Wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ Wrapper.displayName = 'TestWrapper';
+ return Wrapper;
+ };
+
+ const sampleConversation: Conversation = {
+ id: 'conversation-1',
+ participant: { username: 'alice', displayName: 'Alice', avatarUrl: null },
+ lastMessage: {
+ content: 'Old',
+ senderUsername: 'alice',
+ sentAt: '2024-01-01T00:00:00.000Z',
+ },
+ isMuted: false,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ accessToken = 'token-123';
+ activeConversationId = null;
+ setUnseenCount = jest.fn();
+ setFollowingNewTweetAuthors = jest.fn();
+
+ mockUseSessionStore.mockImplementation((selector: (state: unknown) => unknown) => {
+ const state = {
+ getAccessToken: () => accessToken,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ mockUseDmStore.mockImplementation((selector: (state: unknown) => unknown) => {
+ const state = {
+ unseenCount: 0,
+ setUnseenCount,
+ activeConversationId,
+ setActiveConversationId: (id: string | null) => {
+ activeConversationId = id;
+ },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ mockUseTimelineStore.mockImplementation((selector: (state: unknown) => unknown) => {
+ const state = {
+ followingNewTweetAuthors: null,
+ setFollowingNewTweetAuthors,
+ clearFollowingNewTweetAuthors: () => setFollowingNewTweetAuthors(null),
+ };
+ return selector ? selector(state) : state;
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('initializes SSE connection when enabled', async () => {
+ const { result } = renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isConnected).toBe(true);
+ });
+
+ const instance = listenersModule.__getLastInstance();
+ expect(instance).not.toBeNull();
+ expect(instance?.addEventListener).toHaveBeenCalledWith(
+ 'notifications.count_update',
+ expect.any(Function)
+ );
+ expect(instance?.addEventListener).toHaveBeenCalledWith(
+ 'notifications.new',
+ expect.any(Function)
+ );
+ });
+
+ it('invalidates notification queries when count_update event is received', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
+
+ act(() => {
+ listenersModule.__listeners['notifications.count_update']({
+ data: JSON.stringify({ count: 10 }),
+ });
+ });
+
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['notifications', 'count'] });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['notifications'] });
+ });
+
+ it('invalidates notifications when new notification event is received', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
+
+ act(() => {
+ listenersModule.__listeners['notifications.new']({
+ data: JSON.stringify({
+ id: '1',
+ type: 'LIKE',
+ createdAt: '2025-12-09T00:00:00Z',
+ }),
+ });
+ });
+
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['notifications', 'count'] });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['notifications'] });
+ });
+
+ it('handles malformed new notification data gracefully', async () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ act(() => {
+ listenersModule.__listeners['notifications.new']({
+ data: 'invalid json',
+ });
+ });
+
+ // Since the handler doesn't parse JSON, no warning should be logged
+ expect(warnSpy).not.toHaveBeenCalled();
+
+ warnSpy.mockRestore();
+ });
+
+ it('handles empty data gracefully', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ // Should not throw
+ act(() => {
+ listenersModule.__listeners['notifications.count_update']({ data: undefined });
+ listenersModule.__listeners['notifications.new']({ data: undefined });
+ });
+ });
+
+ it('cleans up on unmount', async () => {
+ const { unmount } = renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const instance = listenersModule.__getLastInstance();
+
+ unmount();
+
+ await waitFor(() => {
+ expect(instance?.close).toHaveBeenCalled();
+ expect(instance?.removeAllEventListeners).toHaveBeenCalled();
+ });
+ });
+
+ // DM SSE Tests
+ describe('DM events', () => {
+ it('updates DM unseen count when dm.unseen_conversations_count event is received', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ act(() => {
+ listenersModule.__listeners['dm.unseen_conversations_count']?.({
+ data: JSON.stringify({ count: 3 }),
+ });
+ });
+
+ expect(setUnseenCount).toHaveBeenCalledWith(3);
+ });
+
+ it('updates conversation cache when dm.new_message event is received', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ queryClient.setQueryData(dmKeys.conversations(), [sampleConversation]);
+
+ const newMessagePayload = {
+ conversationId: 'conversation-1',
+ messageId: 'm-1',
+ sender: { id: 's-1', username: 'alice', displayName: 'Alice', avatarUrl: null },
+ bodySnippet: 'Latest hello',
+ createdAt: '2024-02-01T00:00:00.000Z',
+ hasMedia: true,
+ };
+
+ await act(async () => {
+ listenersModule.__listeners['dm.new_message']?.({
+ data: JSON.stringify(newMessagePayload),
+ });
+ });
+
+ await waitFor(() => {
+ const updated = queryClient.getQueryData(dmKeys.conversations()) as Conversation[];
+ expect(updated[0].lastMessage?.content).toBe('Latest hello');
+ expect(updated[0].lastMessage?.hasMedia).toBe(true);
+ });
+ });
+
+ it('invalidates conversations query when conversation not found', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ queryClient.setQueryData(dmKeys.conversations(), [sampleConversation]);
+ const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
+
+ const newMessagePayload = {
+ conversationId: 'missing-conversation',
+ messageId: 'm-1',
+ sender: { id: 's-1', username: 'alice', displayName: 'Alice', avatarUrl: null },
+ bodySnippet: 'Hello',
+ createdAt: '2024-02-01T00:00:00.000Z',
+ hasMedia: false,
+ };
+
+ await act(async () => {
+ listenersModule.__listeners['dm.new_message']?.({
+ data: JSON.stringify(newMessagePayload),
+ });
+ });
+
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: dmKeys.conversations() });
+ });
+
+ it('marks new messages as seen when their conversation is open', async () => {
+ activeConversationId = 'conversation-1';
+ mockUseDmStore.mockImplementation((selector: (state: unknown) => unknown) => {
+ const state = {
+ unseenCount: 0,
+ setUnseenCount,
+ activeConversationId: 'conversation-1',
+ setActiveConversationId: jest.fn(),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ queryClient.setQueryData(dmKeys.conversations(), [sampleConversation]);
+
+ const newMessagePayload = {
+ conversationId: 'conversation-1',
+ messageId: 'm-open',
+ sender: { id: 's-1', username: 'alice', displayName: 'Alice', avatarUrl: null },
+ bodySnippet: 'Hi while open',
+ createdAt: '2024-02-01T00:00:00.000Z',
+ hasMedia: false,
+ };
+
+ await act(async () => {
+ listenersModule.__listeners['dm.new_message']?.({
+ data: JSON.stringify(newMessagePayload),
+ });
+ });
+
+ await waitFor(() => {
+ const updated = queryClient.getQueryData(dmKeys.conversations()) as Conversation[];
+ expect(updated[0].lastMessage?.seen).toBe(true);
+ });
+ });
+
+ it('updates paginated conversation cache', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const pagedConversation: Conversation = { ...sampleConversation, id: 'paged' };
+ queryClient.setQueryData(dmKeys.conversations(), {
+ pages: [{ data: [pagedConversation] }],
+ pageParams: [undefined],
+ });
+
+ const basePayload = {
+ conversationId: 'paged',
+ messageId: 'm-2',
+ sender: { id: 's-2', username: 'alice', displayName: 'Alice', avatarUrl: null },
+ bodySnippet: 'Paged hello',
+ createdAt: '2024-03-01T00:00:00.000Z',
+ hasMedia: false,
+ };
+
+ await act(async () => {
+ listenersModule.__listeners['dm.new_message']?.({ data: JSON.stringify(basePayload) });
+ });
+
+ await waitFor(() => {
+ const updated = queryClient.getQueryData(dmKeys.conversations()) as {
+ pages: { data: Conversation[] }[];
+ };
+ expect(updated.pages[0].data[0].lastMessage?.content).toBe('Paged hello');
+ });
+ });
+
+ it('updates response-shaped conversation cache', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const responseConversation: Conversation = { ...sampleConversation, id: 'success' };
+ queryClient.setQueryData(dmKeys.conversations(), {
+ data: [responseConversation],
+ success: true,
+ message: 'ok',
+ });
+
+ const basePayload = {
+ conversationId: 'success',
+ messageId: 'm-3',
+ sender: { id: 's-3', username: 'alice', displayName: 'Alice', avatarUrl: null },
+ bodySnippet: 'Updated',
+ createdAt: '2024-03-01T00:00:00.000Z',
+ hasMedia: false,
+ };
+
+ await act(async () => {
+ listenersModule.__listeners['dm.new_message']?.({
+ data: JSON.stringify(basePayload),
+ });
+ });
+
+ await waitFor(() => {
+ const updated = queryClient.getQueryData(dmKeys.conversations()) as {
+ data: Conversation[];
+ };
+ expect(updated.data[0].lastMessage?.content).toBe('Updated');
+ });
+ });
+
+ it('leaves unsupported cache shapes untouched', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ queryClient.setQueryData(dmKeys.conversations(), 'unsupported-shape');
+
+ const payload = {
+ conversationId: 'any',
+ messageId: 'm-x',
+ sender: { id: 's-x', username: 'x', displayName: 'x', avatarUrl: null },
+ bodySnippet: 'hello',
+ createdAt: '2025-11-01T00:00:00.000Z',
+ hasMedia: false,
+ };
+
+ await act(async () => {
+ listenersModule.__listeners['dm.new_message']?.({ data: JSON.stringify(payload) });
+ });
+
+ expect(queryClient.getQueryData(dmKeys.conversations())).toBe('unsupported-shape');
+ });
+ });
+
+ describe('Timeline events', () => {
+ it('registers timeline.following event listener', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const instance = listenersModule.__getLastInstance();
+ expect(instance?.addEventListener).toHaveBeenCalledWith(
+ 'timeline.following',
+ expect.any(Function)
+ );
+ });
+
+ it('updates followingNewTweetAuthors when timeline.following event is received', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const authorAvatars = ['https://example.com/avatar1.jpg', 'https://example.com/avatar2.jpg'];
+
+ act(() => {
+ listenersModule.__listeners['timeline.following']?.({
+ data: JSON.stringify({ authors: authorAvatars }),
+ });
+ });
+
+ expect(setFollowingNewTweetAuthors).toHaveBeenCalledWith(authorAvatars);
+ });
+
+ it('sets followingNewTweetAuthors to null when timeline.following event has null authors', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ act(() => {
+ listenersModule.__listeners['timeline.following']?.({
+ data: JSON.stringify({ authors: null }),
+ });
+ });
+
+ expect(setFollowingNewTweetAuthors).toHaveBeenCalledWith(null);
+ });
+
+ it('handles empty authors array in timeline.following event', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ act(() => {
+ listenersModule.__listeners['timeline.following']?.({
+ data: JSON.stringify({ authors: [] }),
+ });
+ });
+
+ expect(setFollowingNewTweetAuthors).toHaveBeenCalledWith([]);
+ });
+
+ it('handles malformed timeline.following data gracefully', async () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ act(() => {
+ listenersModule.__listeners['timeline.following']?.({
+ data: 'invalid json',
+ });
+ });
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ '[SSE] Failed to parse timeline.following event:',
+ expect.any(Error)
+ );
+ expect(setFollowingNewTweetAuthors).not.toHaveBeenCalled();
+
+ warnSpy.mockRestore();
+ });
+
+ it('handles empty data in timeline.following event gracefully', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ act(() => {
+ listenersModule.__listeners['timeline.following']?.({ data: undefined });
+ });
+
+ expect(setFollowingNewTweetAuthors).not.toHaveBeenCalled();
+ });
+
+ it('handles null data in timeline.following event gracefully', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ act(() => {
+ listenersModule.__listeners['timeline.following']?.({ data: null as unknown as string });
+ });
+
+ expect(setFollowingNewTweetAuthors).not.toHaveBeenCalled();
+ });
+
+ it('subscribes to timeline topic when notifications enabled', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const instance = listenersModule.__getLastInstance();
+ expect(instance?.addEventListener).toHaveBeenCalledWith(
+ 'timeline.following',
+ expect.any(Function)
+ );
+ });
+
+ it('subscribes to timeline topic when notifications disabled', async () => {
+ renderHook(() => useRealtimeEvents(false), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const instance = listenersModule.__getLastInstance();
+ expect(instance?.addEventListener).toHaveBeenCalledWith(
+ 'timeline.following',
+ expect.any(Function)
+ );
+ });
+
+ it('handles single author in timeline.following event', async () => {
+ renderHook(() => useRealtimeEvents(true), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const singleAuthor = ['https://example.com/avatar.jpg'];
+
+ act(() => {
+ listenersModule.__listeners['timeline.following']?.({
+ data: JSON.stringify({ authors: singleAuthor }),
+ });
+ });
+
+ expect(setFollowingNewTweetAuthors).toHaveBeenCalledWith(singleAuthor);
+ });
+ });
+});
diff --git a/src/__tests__/hooks/useSSE.test.tsx b/src/__tests__/hooks/useSSE.test.tsx
index 6c39e0885..095f9fb62 100644
--- a/src/__tests__/hooks/useSSE.test.tsx
+++ b/src/__tests__/hooks/useSSE.test.tsx
@@ -100,7 +100,6 @@ describe('useSSE', () => {
() =>
useSSE({
topics: ['test'],
- enabled: true,
eventHandlers,
}),
{ wrapper: createWrapper() }
@@ -115,28 +114,6 @@ describe('useSSE', () => {
expect(instance?.addEventListener).toHaveBeenCalledWith('test.event', testHandler);
});
- it('does not initialize when disabled', async () => {
- const testHandler = jest.fn();
- const eventHandlers = [
- {
- event: 'test.event' as const,
- handler: testHandler,
- },
- ];
-
- const { result } = renderHook(
- () =>
- useSSE({
- topics: ['test'],
- enabled: false,
- eventHandlers,
- }),
- { wrapper: createWrapper() }
- );
-
- expect(result.current.isConnected).toBe(false);
- });
-
it('does not initialize when no token is available', async () => {
accessToken = '';
const testHandler = jest.fn();
@@ -151,7 +128,6 @@ describe('useSSE', () => {
() =>
useSSE({
topics: ['test'],
- enabled: true,
eventHandlers,
}),
{ wrapper: createWrapper() }
@@ -171,7 +147,6 @@ describe('useSSE', () => {
() =>
useSSE({
topics: ['test'],
- enabled: true,
eventHandlers,
}),
{ wrapper: createWrapper() }
@@ -202,7 +177,6 @@ describe('useSSE', () => {
() =>
useSSE({
topics: ['test'],
- enabled: true,
eventHandlers,
debug: true,
}),
@@ -238,7 +212,6 @@ describe('useSSE', () => {
() =>
useSSE({
topics: ['test'],
- enabled: true,
eventHandlers,
}),
{ wrapper: createWrapper() }
@@ -259,7 +232,6 @@ describe('useSSE', () => {
() =>
useSSE({
topics: ['test'],
- enabled: true,
eventHandlers,
}),
{ wrapper: createWrapper() }
@@ -286,7 +258,6 @@ describe('useSSE', () => {
() =>
useSSE({
topics: ['test1', 'test2', 'test3'],
- enabled: true,
eventHandlers,
}),
{ wrapper: createWrapper() }
@@ -310,7 +281,6 @@ describe('useSSE', () => {
() =>
useSSE({
topics: ['test'],
- enabled: true,
eventHandlers,
}),
{ wrapper: createWrapper() }
@@ -329,4 +299,142 @@ describe('useSSE', () => {
expect(instance?.removeAllEventListeners).toHaveBeenCalled();
});
});
+
+ it('handles initialization failure', async () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ listenersModule.__setShouldThrow(true);
+
+ const testHandler = jest.fn();
+ const eventHandlers = [
+ {
+ event: 'test.event' as const,
+ handler: testHandler,
+ },
+ ];
+
+ renderHook(
+ () =>
+ useSSE({
+ topics: ['test'],
+ eventHandlers,
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ '[SSE] Failed to initialize connection:',
+ expect.any(Error)
+ );
+ warnSpy.mockRestore();
+ });
+
+ it('handles errors during cleanup on unmount with debug mode', async () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ const testHandler = jest.fn();
+ const eventHandlers = [{ event: 'test.event' as const, handler: testHandler }];
+
+ const { unmount } = renderHook(
+ () =>
+ useSSE({
+ topics: ['test'],
+ eventHandlers,
+ debug: true,
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const instance = listenersModule.__getLastInstance();
+ if (instance) {
+ instance.close.mockImplementation(() => {
+ throw new Error('Cleanup error');
+ });
+ }
+
+ unmount();
+
+ expect(warnSpy).toHaveBeenCalledWith('[SSE] Error during cleanup:', expect.any(Error));
+ warnSpy.mockRestore();
+ });
+
+ it('handles errors during re-initialization cleanup with debug mode', async () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ const testHandler = jest.fn();
+ const eventHandlers = [{ event: 'test.event' as const, handler: testHandler }];
+
+ const { rerender } = renderHook(
+ (props: { topics: string[] }) =>
+ useSSE({
+ topics: props.topics,
+ eventHandlers,
+ debug: true,
+ }),
+ {
+ wrapper: createWrapper(),
+ initialProps: { topics: ['test'] },
+ }
+ );
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const instance = listenersModule.__getLastInstance();
+ if (instance) {
+ instance.close.mockImplementation(() => {
+ throw new Error('Re-init cleanup error');
+ });
+ }
+
+ // Trigger re-init by changing topics
+ rerender({ topics: ['test2'] });
+
+ expect(warnSpy).toHaveBeenCalledWith('[SSE] Error during cleanup:', expect.any(Error));
+ warnSpy.mockRestore();
+ });
+
+ it('handles errors during disconnect (empty token) cleanup with debug mode', async () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ const testHandler = jest.fn();
+ const eventHandlers = [{ event: 'test.event' as const, handler: testHandler }];
+
+ // Mock store to allow dynamic updates
+ let currentToken: string | null = 'token-123';
+ mockUseSessionStore.mockImplementation((selector: (state: unknown) => unknown) => {
+ const state = { getAccessToken: () => currentToken };
+ return selector ? selector(state) : state;
+ });
+
+ const { rerender } = renderHook(
+ () =>
+ useSSE({
+ topics: ['test'],
+ eventHandlers,
+ debug: true,
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(listenersModule.__getLastInstance()).not.toBeNull();
+ });
+
+ const instance = listenersModule.__getLastInstance();
+ if (instance) {
+ instance.close.mockImplementation(() => {
+ throw new Error('Disconnect cleanup error');
+ });
+ }
+
+ // Set token to null to trigger disconnect
+ currentToken = null;
+ // Force re-render to pick up new token value
+ rerender({});
+
+ expect(warnSpy).toHaveBeenCalledWith('[SSE] Error during cleanup:', expect.any(Error));
+ warnSpy.mockRestore();
+ });
});
diff --git a/src/__tests__/hooks/useSendMessage.test.tsx b/src/__tests__/hooks/useSendMessage.test.tsx
new file mode 100644
index 000000000..7a7d0d7f7
--- /dev/null
+++ b/src/__tests__/hooks/useSendMessage.test.tsx
@@ -0,0 +1,362 @@
+/* eslint-disable react/display-name */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react-native';
+
+import { useSendMessage } from '@/hooks/useSendMessage';
+import * as dmService from '@/services/dm';
+import * as socketService from '@/services/socket';
+
+jest.mock('@/stores/userStore', () => ({
+ useUserStore: (selector: any) =>
+ selector({
+ user: { username: 'testuser', displayName: 'Test User', avatarUrl: 'avatar.jpg' },
+ }),
+}));
+
+const mockSocket = {
+ emit: jest.fn(),
+ on: jest.fn(),
+ off: jest.fn(),
+ connected: true,
+};
+
+jest.spyOn(socketService, 'getSocket').mockReturnValue(mockSocket as any);
+
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+describe('useSendMessage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('sends text message with socket emit', async () => {
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ result.current.sendMessage('Hello world');
+
+ await waitFor(() => {
+ expect(mockSocket.emit).toHaveBeenCalledWith(
+ 'send_message',
+ expect.objectContaining({
+ type: 'send_message',
+ conversationId: 'conv1',
+ body: 'Hello world',
+ })
+ );
+ expect(mockSocket.emit).toHaveBeenCalledWith(
+ 'send_message',
+ expect.objectContaining({
+ clientMessageId: expect.stringContaining('optimistic-'),
+ })
+ );
+ });
+ });
+
+ it('trims whitespace before sending', async () => {
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ result.current.sendMessage(' test message ');
+
+ await waitFor(() => {
+ expect(mockSocket.emit).toHaveBeenCalledWith(
+ 'send_message',
+ expect.objectContaining({
+ body: 'test message',
+ })
+ );
+ });
+ });
+
+ it('does not send empty message without media', () => {
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ result.current.sendMessage(' ');
+
+ expect(mockSocket.emit).not.toHaveBeenCalled();
+ });
+
+ it('sends media without text content', async () => {
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ result.current.sendMessage('', 'https://example.com/image.jpg', 'photo');
+
+ await waitFor(() => {
+ expect(mockSocket.emit).toHaveBeenCalledWith(
+ 'send_message',
+ expect.objectContaining({
+ body: '',
+ mediaUrl: 'https://example.com/image.jpg',
+ })
+ );
+ });
+ });
+
+ it('generates unique clientMessageId for each message', async () => {
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ result.current.sendMessage('First');
+
+ await waitFor(() => {
+ expect(mockSocket.emit).toHaveBeenCalledTimes(1);
+ });
+
+ result.current.sendMessage('Second');
+
+ await waitFor(() => {
+ expect(mockSocket.emit).toHaveBeenCalledTimes(2);
+ });
+
+ const firstCall = mockSocket.emit.mock.calls[0][1];
+ const secondCall = mockSocket.emit.mock.calls[1][1];
+
+ expect(firstCall.clientMessageId).toContain('optimistic-');
+ expect(secondCall.clientMessageId).toContain('optimistic-');
+ expect(firstCall.clientMessageId).not.toBe(secondCall.clientMessageId);
+ });
+
+ it('uploads local file before sending', async () => {
+ const uploadSpy = jest.spyOn(dmService, 'uploadMessageImage').mockResolvedValue({
+ data: {
+ url: 'https://cdn.example.com/uploaded.jpg',
+ id: 'media-123',
+ message: '',
+ },
+ success: true,
+ });
+
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ result.current.sendMessage('Check this out', 'file://local-image.jpg', 'photo');
+
+ await waitFor(() => {
+ expect(uploadSpy).toHaveBeenCalled();
+ expect(mockSocket.emit).toHaveBeenCalledWith(
+ 'send_message',
+ expect.objectContaining({
+ mediaUrl: 'https://cdn.example.com/uploaded.jpg',
+ mediaId: 'media-123',
+ })
+ );
+ });
+ });
+
+ it('uploads video with uploadMessageVideo service', async () => {
+ const uploadSpy = jest.spyOn(dmService, 'uploadMessageVideo').mockResolvedValue({
+ data: {
+ url: 'https://cdn.example.com/video.mp4',
+ id: 'media-456',
+ message: '',
+ },
+ success: true,
+ });
+
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ result.current.sendMessage('', 'file://local-video.mp4', 'video');
+
+ await waitFor(() => {
+ expect(uploadSpy).toHaveBeenCalled();
+ expect(mockSocket.emit).toHaveBeenCalledWith(
+ 'send_message',
+ expect.objectContaining({
+ mediaUrl: 'https://cdn.example.com/video.mp4',
+ mediaId: 'media-456',
+ })
+ );
+ });
+ });
+
+ it('handles upload failure by throwing error', async () => {
+ jest.spyOn(dmService, 'uploadMessageImage').mockRejectedValue(new Error('Upload failed'));
+ jest.spyOn(console, 'error').mockImplementation();
+
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ result.current.sendMessage('', 'file://broken.jpg', 'photo');
+
+ await waitFor(() => {
+ expect(console.error).toHaveBeenCalledWith('Failed to upload media:', expect.any(Error));
+ });
+
+ expect(mockSocket.emit).not.toHaveBeenCalled();
+ });
+
+ it('sets isUploading to true during mutation', async () => {
+ jest.spyOn(dmService, 'uploadMessageImage').mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(() => {
+ resolve({
+ data: {
+ url: 'https://cdn.example.com/uploaded.jpg',
+ id: 'media-123',
+ message: '',
+ },
+ success: true,
+ });
+ }, 100);
+ })
+ );
+
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ expect(result.current.isUploading).toBe(false);
+
+ result.current.sendMessage('', 'file://image.jpg', 'photo');
+
+ await waitFor(() => {
+ expect(result.current.isUploading).toBe(true);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isUploading).toBe(false);
+ });
+ });
+
+ it('handles participant being null', async () => {
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: null,
+ }),
+ { wrapper: createWrapper() }
+ );
+
+ result.current.sendMessage('Test');
+
+ await waitFor(() => {
+ expect(mockSocket.emit).toHaveBeenCalledWith(
+ 'send_message',
+ expect.objectContaining({
+ body: 'Test',
+ })
+ );
+ });
+ });
+
+ it('creates optimistic message with correct structure', async () => {
+ const queryClient = new QueryClient();
+
+ // Set up initial query data
+ queryClient.setQueryData(['dm', 'conversations', 'conv1', 'messages'], {
+ pages: [
+ {
+ data: {
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ messages: [],
+ },
+ pagination: { hasNextPage: false, nextCursor: null },
+ },
+ ],
+ pageParams: [null],
+ });
+
+ const { result } = renderHook(
+ () =>
+ useSendMessage({
+ conversationId: 'conv1',
+ participant: { username: 'other', displayName: 'Other', avatarUrl: null },
+ }),
+ {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ }
+ );
+
+ result.current.sendMessage('Optimistic test');
+
+ const queryData = queryClient.getQueryData(['dm', 'conversations', 'conv1', 'messages']) as any;
+ const firstMessage = queryData.pages[0].data.messages[0];
+
+ expect(firstMessage).toMatchObject({
+ id: expect.stringContaining('optimistic-'),
+ content: 'Optimistic test',
+ isMine: true,
+ reactions: {
+ sender: {
+ username: 'testuser',
+ displayName: 'Test User',
+ avatarUrl: 'avatar.jpg',
+ },
+ receiver: {
+ username: 'other',
+ displayName: 'Other',
+ avatarUrl: null,
+ },
+ },
+ });
+ });
+});
diff --git a/src/__tests__/hooks/useTweetDetail.test.tsx b/src/__tests__/hooks/useTweetDetail.test.tsx
new file mode 100644
index 000000000..1f83fe799
--- /dev/null
+++ b/src/__tests__/hooks/useTweetDetail.test.tsx
@@ -0,0 +1,156 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react-native';
+
+import { useTweetDetail } from '@/hooks/tweets/useTweetDetail';
+import { getTweetCache } from '@/libs/tweetCache';
+import { getTweet } from '@/services/tweets';
+import { Tweet } from '@/types/tweet';
+
+jest.mock('@/services/tweets');
+jest.mock('@/libs/tweetCache', () => {
+ const actual = jest.requireActual('@/libs/tweetCache');
+ return {
+ ...actual,
+ getTweetCache: jest.fn(),
+ };
+});
+
+const mockTweet: Tweet = {
+ id: 'tweet-123',
+ content: 'Hello world',
+ author: {
+ username: 'testuser',
+ displayName: 'Test User',
+ avatarUrl: 'https://example.com/avatar.jpg',
+ relationship: {
+ follower: false,
+ following: false,
+ muted: false,
+ blockedBy: false,
+ blocking: false,
+ },
+ },
+ createdAt: '2024-01-01T00:00:00Z',
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 0,
+ isLiked: false,
+ isRetweeted: false,
+ entities: { mentions: [], hashtags: [] },
+ media: [],
+ replyToTweetId: null,
+ quoteToTweetId: null,
+ quotedTweet: null,
+};
+
+describe('useTweetDetail', () => {
+ let queryClient: QueryClient;
+ let mockSetTweet: jest.Mock;
+ let mockSetTweets: jest.Mock;
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ mockSetTweet = jest.fn();
+ mockSetTweets = jest.fn();
+
+ (getTweetCache as jest.Mock).mockReturnValue({
+ setTweet: mockSetTweet,
+ setTweets: mockSetTweets,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('fetches and returns tweet data successfully', async () => {
+ (getTweet as jest.Mock).mockResolvedValue({
+ success: true,
+ data: mockTweet,
+ });
+
+ const { result } = renderHook(() => useTweetDetail('tweet-123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data).toEqual(mockTweet);
+ expect(getTweet).toHaveBeenCalledWith('tweet-123');
+ expect(mockSetTweet).toHaveBeenCalledWith(mockTweet);
+ });
+
+ it('caches root tweet if it exists', async () => {
+ const tweetWithRoot = {
+ ...mockTweet,
+ rootTweet: { ...mockTweet, id: 'root-123' },
+ };
+
+ (getTweet as jest.Mock).mockResolvedValue({
+ success: true,
+ data: tweetWithRoot,
+ });
+
+ const { result } = renderHook(() => useTweetDetail('tweet-123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(mockSetTweet).toHaveBeenCalledWith(tweetWithRoot.rootTweet);
+ });
+
+ it('caches parent tweets if they exist', async () => {
+ const parent1 = { ...mockTweet, id: 'parent-1' };
+ const parent2 = { ...mockTweet, id: 'parent-2' };
+
+ // Simulate one deleted parent
+ const deletedParent = { isDeleted: true };
+
+ const tweetWithParents = {
+ ...mockTweet,
+ parentTweets: [parent1, deletedParent, parent2],
+ };
+
+ (getTweet as jest.Mock).mockResolvedValue({
+ success: true,
+ data: tweetWithParents,
+ });
+
+ const { result } = renderHook(() => useTweetDetail('tweet-123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(mockSetTweets).toHaveBeenCalledWith([parent1, parent2]);
+ });
+
+ it('throws error when fetch fails', async () => {
+ (getTweet as jest.Mock).mockResolvedValue({
+ success: false,
+ });
+
+ const { result } = renderHook(() => useTweetDetail('tweet-123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 5000 });
+ expect(result.current.error).toEqual(new Error('Failed to fetch tweet'));
+ });
+
+ it('does not retry on 404 error', async () => {
+ (getTweet as jest.Mock).mockImplementation(() => {
+ const error: Error & { status?: number } = new Error('Not Found');
+ error.status = 404;
+ throw error;
+ });
+
+ const { result } = renderHook(() => useTweetDetail('tweet-123'), { wrapper });
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(result.current.failureCount).toBe(1);
+ });
+});
diff --git a/src/__tests__/hooks/useTweetReplies.test.tsx b/src/__tests__/hooks/useTweetReplies.test.tsx
new file mode 100644
index 000000000..304facf63
--- /dev/null
+++ b/src/__tests__/hooks/useTweetReplies.test.tsx
@@ -0,0 +1,120 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook, waitFor } from '@testing-library/react-native';
+
+import { useTweetReplies } from '@/hooks/tweets/useTweetReplies';
+import { getTweetCache } from '@/libs/tweetCache';
+import { getTweetReplies } from '@/services/tweets';
+import { Tweet } from '@/types/tweet';
+
+jest.mock('@/services/tweets');
+jest.mock('@/libs/tweetCache', () => {
+ const actual = jest.requireActual('@/libs/tweetCache');
+ return {
+ ...actual,
+ getTweetCache: jest.fn(),
+ };
+});
+
+const mockTweet: Tweet = {
+ id: 'tweet-123',
+ content: 'Reply tweet',
+ author: {
+ username: 'user2',
+ displayName: 'User Two',
+ avatarUrl: null,
+ relationship: {
+ follower: false,
+ following: false,
+ muted: false,
+ blockedBy: false,
+ blocking: false,
+ },
+ },
+ createdAt: '2024-01-01T00:00:00Z',
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 0,
+ isLiked: false,
+ isRetweeted: false,
+ entities: { mentions: [], hashtags: [] },
+ media: [],
+ replyToTweetId: 'parent-123',
+ quoteToTweetId: null,
+ quotedTweet: null,
+};
+
+describe('useTweetReplies', () => {
+ let queryClient: QueryClient;
+ let mockSetTweets: jest.Mock;
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ mockSetTweets = jest.fn();
+ (getTweetCache as jest.Mock).mockReturnValue({
+ setTweets: mockSetTweets,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('fetches replies and populates cache', async () => {
+ (getTweetReplies as jest.Mock).mockResolvedValue({
+ data: [mockTweet],
+ pagination: {
+ hasNextPage: true,
+ nextCursor: 'next-123',
+ },
+ });
+
+ const { result } = renderHook(() => useTweetReplies('parent-123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data?.pages[0].data).toEqual([mockTweet]);
+ expect(mockSetTweets).toHaveBeenCalledWith([mockTweet]);
+ });
+
+ it('handles empty response gracefully', async () => {
+ (getTweetReplies as jest.Mock).mockResolvedValue({
+ data: [],
+ pagination: { hasNextPage: false },
+ });
+
+ const { result } = renderHook(() => useTweetReplies('parent-123'), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.pages[0].data).toEqual([]);
+ expect(mockSetTweets).toHaveBeenCalledWith([]);
+ });
+
+ it('correctly determines next page param', async () => {
+ (getTweetReplies as jest.Mock).mockResolvedValue({
+ data: [mockTweet],
+ pagination: {
+ hasNextPage: true,
+ nextCursor: 'next-cursor-123',
+ },
+ });
+
+ const { result } = renderHook(() => useTweetReplies('parent-123'), { wrapper });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ await result.current.fetchNextPage();
+
+ expect(getTweetReplies).toHaveBeenCalledTimes(2);
+ expect(getTweetReplies).toHaveBeenLastCalledWith('parent-123', { cursor: 'next-cursor-123' });
+ });
+});
diff --git a/src/__tests__/libs/tweetCache.test.ts b/src/__tests__/libs/tweetCache.test.ts
new file mode 100644
index 000000000..4e2a30aa8
--- /dev/null
+++ b/src/__tests__/libs/tweetCache.test.ts
@@ -0,0 +1,163 @@
+import { QueryClient } from '@tanstack/react-query';
+
+import { queryKeys } from '@/libs/queryKeys';
+import { TweetCacheManager, getTweetCache, resetTweetCache } from '@/libs/tweetCache';
+import { Tweet } from '@/types/tweet';
+
+const mockTweet: Tweet = {
+ id: 'tweet-123',
+ content: 'Cache me',
+ author: {
+ username: 'cacheuser',
+ displayName: 'Cache User',
+ avatarUrl: null,
+ relationship: {
+ follower: false,
+ following: false,
+ muted: false,
+ blockedBy: false,
+ blocking: false,
+ },
+ },
+ createdAt: '2024-01-01T00:00:00Z',
+ replyCount: 5,
+ retweetCount: 10,
+ likeCount: 20,
+ isLiked: false,
+ isRetweeted: false,
+ entities: { mentions: [], hashtags: [] },
+ media: [],
+ replyToTweetId: null,
+ quoteToTweetId: null,
+ quotedTweet: null,
+};
+
+describe('TweetCacheManager', () => {
+ let queryClient: QueryClient;
+ let cacheManager: TweetCacheManager;
+
+ beforeEach(() => {
+ queryClient = new QueryClient();
+ cacheManager = new TweetCacheManager(queryClient);
+ });
+
+ it('sets and gets a tweet', () => {
+ cacheManager.setTweet(mockTweet);
+ const retrieved = cacheManager.getTweet('tweet-123');
+ expect(retrieved).toEqual(mockTweet);
+ });
+
+ it('updates a tweet partially', () => {
+ cacheManager.setTweet(mockTweet);
+ cacheManager.updateTweet('tweet-123', { isLiked: true });
+
+ const updated = cacheManager.getTweet('tweet-123');
+ expect(updated?.isLiked).toBe(true);
+ expect(updated?.content).toBe(mockTweet.content);
+ });
+
+ it('updates like status and count', () => {
+ cacheManager.setTweet(mockTweet); // likeCount: 20, isLiked: false
+
+ cacheManager.updateLike('tweet-123', true);
+ let updated = cacheManager.getTweet('tweet-123');
+ expect(updated?.isLiked).toBe(true);
+ expect(updated?.likeCount).toBe(21);
+
+ cacheManager.updateLike('tweet-123', false);
+ updated = cacheManager.getTweet('tweet-123');
+ expect(updated?.isLiked).toBe(false);
+ expect(updated?.likeCount).toBe(20);
+
+ // Test non-negative
+ const zeroLikesTweet = { ...mockTweet, id: 'zero-likes', likeCount: 0 };
+ cacheManager.setTweet(zeroLikesTweet);
+ cacheManager.updateLike('zero-likes', false);
+ updated = cacheManager.getTweet('zero-likes');
+ expect(updated?.likeCount).toBe(0);
+ });
+
+ it('updates retweet status and count', () => {
+ cacheManager.setTweet(mockTweet); // retweetCount: 10, isRetweeted: false
+
+ cacheManager.updateRetweet('tweet-123', true);
+ let updated = cacheManager.getTweet('tweet-123');
+ expect(updated?.isRetweeted).toBe(true);
+ expect(updated?.retweetCount).toBe(11);
+
+ cacheManager.updateRetweet('tweet-123', false);
+ updated = cacheManager.getTweet('tweet-123');
+ expect(updated?.isRetweeted).toBe(false);
+ expect(updated?.retweetCount).toBe(10);
+ });
+
+ it('increments reply count', () => {
+ cacheManager.setTweet(mockTweet); // replyCount: 5
+ cacheManager.incrementReplyCount('tweet-123');
+
+ const updated = cacheManager.getTweet('tweet-123');
+ expect(updated?.replyCount).toBe(6);
+ });
+
+ it('sets multiple tweets', () => {
+ const tweet2 = { ...mockTweet, id: 'tweet-456' };
+ cacheManager.setTweets([mockTweet, tweet2]);
+
+ expect(cacheManager.getTweet('tweet-123')).toBeDefined();
+ expect(cacheManager.getTweet('tweet-456')).toBeDefined();
+ });
+
+ it('checks if it has a tweet', () => {
+ expect(cacheManager.hasTweet('tweet-123')).toBe(false);
+ cacheManager.setTweet(mockTweet);
+ expect(cacheManager.hasTweet('tweet-123')).toBe(true);
+ });
+
+ it('removes a tweet and related queries', () => {
+ const spy = jest.spyOn(queryClient, 'removeQueries');
+ cacheManager.setTweet(mockTweet);
+ cacheManager.removeTweet('tweet-123');
+
+ expect(spy).toHaveBeenCalledWith({ queryKey: queryKeys.tweet('tweet-123') });
+ expect(spy).toHaveBeenCalledWith({ queryKey: queryKeys.tweetReplies('tweet-123') });
+ });
+
+ it('invalidates all tweet queries', () => {
+ const spy = jest.spyOn(queryClient, 'resetQueries');
+ cacheManager.invalidateAllTweetQueries();
+
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it('prefetches a tweet', async () => {
+ const fetcher = jest.fn().mockResolvedValue(mockTweet);
+ const spy = jest.spyOn(queryClient, 'prefetchQuery');
+
+ await cacheManager.prefetchTweet('tweet-123', fetcher);
+ expect(spy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: queryKeys.tweet('tweet-123'),
+ queryFn: fetcher,
+ })
+ );
+ });
+
+ describe('Singleton behavior', () => {
+ beforeEach(() => {
+ resetTweetCache();
+ });
+
+ it('returns same instance for same query client', () => {
+ const instance1 = getTweetCache(queryClient);
+ const instance2 = getTweetCache(queryClient);
+ expect(instance1).toBe(instance2);
+ });
+
+ it('returns new instance for different query client', () => {
+ const instance1 = getTweetCache(queryClient);
+ const newClient = new QueryClient();
+ const instance2 = getTweetCache(newClient);
+ expect(instance1).not.toBe(instance2);
+ });
+ });
+});
diff --git a/src/__tests__/screens/SplashScreen.test.tsx b/src/__tests__/screens/SplashScreen.test.tsx
new file mode 100644
index 000000000..cccffd6d2
--- /dev/null
+++ b/src/__tests__/screens/SplashScreen.test.tsx
@@ -0,0 +1,76 @@
+import { render } from '@testing-library/react-native';
+
+import SplashScreen from '@/screens/SplashScreen';
+
+jest.mock('react-native-reanimated', () => {
+ const Reanimated = jest.requireActual('react-native-reanimated/mock');
+ const { View } = jest.requireActual('react-native');
+
+ Reanimated.default.View = View;
+ return {
+ ...Reanimated,
+ useSharedValue: jest.fn((initial) => ({ value: initial })),
+ useAnimatedStyle: jest.fn(() => ({})),
+ withTiming: jest.fn((toValue) => toValue),
+ withDelay: jest.fn((_, animation) => animation),
+ Easing: {
+ inOut: jest.fn(() => jest.fn()),
+ out: jest.fn(() => jest.fn()),
+ ease: {},
+ },
+ };
+});
+
+jest.mock('@react-native-masked-view/masked-view', () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ };
+});
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'dark' }),
+}));
+
+describe('SplashScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders children content', () => {
+ const { getByTestId } = render(
+
+ <>>
+
+ );
+
+ expect(getByTestId('masked-view')).toBeTruthy();
+ });
+
+ it('renders splash overlay elements', () => {
+ const { getByTestId } = render(
+
+ <>>
+
+ );
+
+ expect(getByTestId('masked-view')).toBeTruthy();
+ });
+
+ it('renders with light theme', () => {
+ jest.doMock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'light' }),
+ }));
+
+ const { getByTestId } = render(
+
+ <>>
+
+ );
+
+ expect(getByTestId('masked-view')).toBeTruthy();
+ });
+});
diff --git a/src/__tests__/screens/accountInformation/AccountInformationScreen.test.tsx b/src/__tests__/screens/accountInformation/AccountInformationScreen.test.tsx
index daafb2abd..7b6401806 100644
--- a/src/__tests__/screens/accountInformation/AccountInformationScreen.test.tsx
+++ b/src/__tests__/screens/accountInformation/AccountInformationScreen.test.tsx
@@ -89,4 +89,88 @@ describe('AccountInformationScreen', () => {
await waitFor(() => expect(mockClearActiveSession).toHaveBeenCalledTimes(1));
});
+
+ it('shows loading state while fetching settings data', async () => {
+ (getSettingsData as jest.Mock).mockImplementation(() => new Promise(() => {})); // Never resolves
+
+ const { getByText } = render( );
+
+ expect(getByText('Loading...')).toBeTruthy();
+
+ // Clean up
+ jest.clearAllMocks();
+ });
+
+ it('handles error when fetching settings data fails', async () => {
+ (getSettingsData as jest.Mock).mockResolvedValue({
+ success: false,
+ error: 'Failed to fetch settings',
+ });
+
+ const { getByText } = render( );
+
+ await waitFor(() => {
+ expect(getByText('No settings data available')).toBeTruthy();
+ });
+ });
+
+ it('truncates long username and email values', async () => {
+ (getSettingsData as jest.Mock).mockResolvedValue({
+ success: true,
+ data: {
+ username: 'verylongusernamethatexceedsnormaldisplaylimits',
+ email: 'verylongemailaddressthatshouldbetruncated@example.com',
+ country: 'United States',
+ },
+ });
+
+ const { getByTestId } = render( );
+
+ await waitFor(() => {
+ const usernameValue = getByTestId('username-row-value').props.children;
+ const emailValue = getByTestId('email-row-value').props.children;
+ expect(usernameValue).toContain('...');
+ expect(emailValue).toContain('...');
+ });
+ });
+
+ it('navigates to country selection screen', async () => {
+ const { getByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('country-row')).toBeTruthy());
+
+ fireEvent.press(getByTestId('country-row'));
+ expect(mockNavigation.navigate).toHaveBeenCalledWith('AccountInformation', {
+ screen: 'ChangeCountry',
+ params: { currentCountry: 'United States' },
+ });
+ });
+
+ it('displays password row with masked value', async () => {
+ const { getByTestId } = render( );
+
+ await waitFor(() => {
+ expect(getByTestId('password-row-value')).toHaveTextContent('••••••••');
+ });
+ });
+
+ it('handles logout and navigates to Start screen', async () => {
+ const { getByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('logout-button')).toBeTruthy());
+
+ fireEvent.press(getByTestId('logout-button'));
+
+ await waitFor(() => {
+ expect(mockClearActiveSession).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('calls getSettingsData on component mount', async () => {
+ render( );
+
+ await waitFor(() => {
+ expect(getSettingsData).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/src/__tests__/screens/auth/password/ConfirmCode.test.tsx b/src/__tests__/screens/auth/password/ConfirmCode.test.tsx
index c54d7fbbd..aed67c8dc 100644
--- a/src/__tests__/screens/auth/password/ConfirmCode.test.tsx
+++ b/src/__tests__/screens/auth/password/ConfirmCode.test.tsx
@@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { NavigationContainer } from '@react-navigation/native';
-import { fireEvent, render, waitFor } from '@testing-library/react-native';
+import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
import { ConfirmCode } from '@/screens/auth/password/ConfirmCode';
import { resendResetOtp, verifyResetCode } from '@/services/auth';
@@ -39,9 +39,14 @@ const mockResendResetOtp = resendResetOtp as jest.MockedFunction {
beforeEach(() => {
jest.clearAllMocks();
+ jest.useFakeTimers();
});
- it('renders correctly with initial state', () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('renders correctly with initial state and timer', () => {
const { getByText, getByTestId } = render(
@@ -51,7 +56,8 @@ describe('ConfirmCode Component', () => {
expect(getByText('We sent you a code')).toBeTruthy();
expect(getByTestId('text-input')).toBeTruthy();
expect(getByText('Confirm')).toBeTruthy();
- expect(getByText('Resend')).toBeTruthy();
+ // Timer should be shown on initial load
+ expect(getByText('Resend code in 60s')).toBeTruthy();
});
it('validates code input', () => {
@@ -126,18 +132,27 @@ describe('ConfirmCode Component', () => {
});
});
- it('handles resend OTP successfully', async () => {
+ it('handles resend OTP successfully and resets timer', async () => {
mockResendResetOtp.mockResolvedValue({
success: true,
message: 'A new code has been sent',
});
- const { getByText } = render(
+ const { getByText, queryByText } = render(
);
+ // Wait for timer to expire so resend button appears
+ act(() => {
+ jest.advanceTimersByTime(60000);
+ });
+
+ await waitFor(() => {
+ expect(getByText('Resend')).toBeTruthy();
+ });
+
const resendButton = getByText('Resend');
fireEvent.press(resendButton);
@@ -147,6 +162,10 @@ describe('ConfirmCode Component', () => {
});
expect(getByText('A new code has been sent to your email')).toBeTruthy();
});
+
+ // Timer should be reset to 60s after successful resend
+ expect(getByText('Resend code in 60s')).toBeTruthy();
+ expect(queryByText(/^Resend$/)).toBeNull();
});
it('handles resend OTP error', async () => {
@@ -161,6 +180,15 @@ describe('ConfirmCode Component', () => {
);
+ // Wait for timer to expire so resend button appears
+ act(() => {
+ jest.advanceTimersByTime(60000);
+ });
+
+ await waitFor(() => {
+ expect(getByText('Resend')).toBeTruthy();
+ });
+
const resendButton = getByText('Resend');
fireEvent.press(resendButton);
@@ -205,13 +233,28 @@ describe('ConfirmCode Component', () => {
);
+ // Wait for timer to expire so resend button appears
+ act(() => {
+ jest.advanceTimersByTime(60000);
+ });
+
+ await waitFor(() => {
+ expect(getByText('Resend')).toBeTruthy();
+ });
+
const resendButton = getByText('Resend');
fireEvent.press(resendButton);
expect(getByText('Sending...')).toBeTruthy();
+ // Advance timers to resolve the promise
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
await waitFor(() => {
- expect(getByText('Resend')).toBeTruthy(); // Button text should return to normal
+ // After successful resend, timer should be shown instead of Resend button
+ expect(getByText('Resend code in 60s')).toBeTruthy();
});
});
@@ -259,4 +302,101 @@ describe('ConfirmCode Component', () => {
expect(mockGoBack).toHaveBeenCalled();
});
+
+ it('timer starts on screen load and prevents immediate resend', async () => {
+ const { getByText, queryByText } = render(
+
+
+
+ );
+
+ // Timer should be shown immediately on load
+ expect(getByText('Resend code in 60s')).toBeTruthy();
+ expect(queryByText(/^Resend$/)).toBeNull();
+
+ // resendResetOtp should not have been called
+ expect(mockResendResetOtp).not.toHaveBeenCalled();
+ });
+
+ it('counts down the timer correctly from initial load', async () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ // Timer should start at 60s on load
+ expect(getByText('Resend code in 60s')).toBeTruthy();
+
+ // Advance timer by 1 second
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ await waitFor(() => {
+ expect(getByText('Resend code in 59s')).toBeTruthy();
+ });
+
+ // Advance timer by 10 more seconds
+ act(() => {
+ jest.advanceTimersByTime(10000);
+ });
+
+ await waitFor(() => {
+ expect(getByText('Resend code in 49s')).toBeTruthy();
+ });
+ });
+
+ it('shows resend button after timer expires on initial load', async () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ // Timer should start at 60s
+ expect(getByText('Resend code in 60s')).toBeTruthy();
+
+ // Advance timer by 60 seconds to expire the timer
+ act(() => {
+ jest.advanceTimersByTime(60000);
+ });
+
+ await waitFor(() => {
+ expect(getByText('Resend')).toBeTruthy();
+ });
+ });
+
+ it('does not start timer on failed resend', async () => {
+ mockResendResetOtp.mockResolvedValue({
+ success: false,
+ message: 'Failed to resend code',
+ });
+
+ const { getByText, queryByText } = render(
+
+
+
+ );
+
+ // Wait for initial timer to expire
+ act(() => {
+ jest.advanceTimersByTime(60000);
+ });
+
+ await waitFor(() => {
+ expect(getByText('Resend')).toBeTruthy();
+ });
+
+ const resendButton = getByText('Resend');
+ fireEvent.press(resendButton);
+
+ await waitFor(() => {
+ expect(getByText('Failed to resend code')).toBeTruthy();
+ });
+
+ // Timer should not restart on failure - resend button should still be available
+ expect(queryByText(/Resend code in/)).toBeNull();
+ expect(getByText('Resend')).toBeTruthy();
+ });
});
diff --git a/src/__tests__/screens/auth/password/ConfirmMethod.test.tsx b/src/__tests__/screens/auth/password/ConfirmMethod.test.tsx
index 0fa0d0cf6..6c92c852d 100644
--- a/src/__tests__/screens/auth/password/ConfirmMethod.test.tsx
+++ b/src/__tests__/screens/auth/password/ConfirmMethod.test.tsx
@@ -7,6 +7,7 @@ import { ConfirmMethod } from '@/screens/auth/password/ConfirmMethod';
import { forgotPassword } from '@/services/auth';
const mockNavigate = jest.fn();
+const mockGoBack = jest.fn();
jest.mock('@expo/vector-icons', () => ({ Ionicons: 'Ionicons' }));
@@ -14,6 +15,7 @@ jest.mock('@react-navigation/native', () => ({
NavigationContainer: ({ children }: { children?: ReactNode }) => <>{children}>,
useNavigation: () => ({
navigate: mockNavigate,
+ goBack: mockGoBack,
}),
useRoute: () => ({
params: { identifier: 'test@example.com' },
@@ -187,4 +189,92 @@ describe('ConfirmMethod Component', () => {
// expect(sendCodeButton).toBeDisabled();
});
+
+ it('handles cancel button press', () => {
+ const { getByLabelText } = render(
+
+
+
+ );
+
+ const cancelButton = getByLabelText('confirm-method-cancel-button');
+ fireEvent.press(cancelButton);
+
+ expect(mockGoBack).toHaveBeenCalled();
+ });
+
+ it('handles exception during forgot password request', async () => {
+ mockForgotPassword.mockRejectedValue(new Error('Network error'));
+
+ const { getByText } = render(
+
+
+
+ );
+
+ const sendCodeButton = getByText('Next');
+ fireEvent.press(sendCodeButton);
+
+ await waitFor(() => {
+ expect(getByText('Network error')).toBeTruthy();
+ });
+ });
+
+ it('handles generic exception during forgot password request', async () => {
+ mockForgotPassword.mockRejectedValue('Some unknown error');
+
+ const { getByText } = render(
+
+
+
+ );
+
+ const sendCodeButton = getByText('Next');
+ fireEvent.press(sendCodeButton);
+
+ await waitFor(() => {
+ expect(getByText('Something went wrong. Please try again.')).toBeTruthy();
+ });
+ });
+
+ it('handles API response without confirmation token', async () => {
+ mockForgotPassword.mockResolvedValue({
+ success: true,
+ message: 'Password reset code sent.',
+ });
+
+ const { getByText } = render(
+
+
+
+ );
+
+ const sendCodeButton = getByText('Next');
+ fireEvent.press(sendCodeButton);
+
+ await waitFor(() => {
+ // Should not navigate without confirmation token
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+ });
+
+ it('handles API error without message', async () => {
+ mockForgotPassword.mockResolvedValue({
+ success: false,
+ });
+
+ const { getByText } = render(
+
+
+
+ );
+
+ const sendCodeButton = getByText('Next');
+ fireEvent.press(sendCodeButton);
+
+ // Should allow retry after error
+ await waitFor(() => {
+ expect(mockForgotPassword).toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/__tests__/screens/explore/EntertainmentScreen.test.tsx b/src/__tests__/screens/explore/EntertainmentScreen.test.tsx
new file mode 100644
index 000000000..51c1e957c
--- /dev/null
+++ b/src/__tests__/screens/explore/EntertainmentScreen.test.tsx
@@ -0,0 +1,163 @@
+import { render } from '@testing-library/react-native';
+
+import * as useEntertainmentTrendsModule from '@/hooks/explore/useEntertainmentTrends';
+import EntertainmentScreen from '@/screens/explore/EntertainmentScreen';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({
+ theme: 'light',
+ }),
+}));
+
+describe('EntertainmentScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the TrendsList with data', () => {
+ jest.spyOn(useEntertainmentTrendsModule, 'useEntertainmentTrends').mockReturnValue({
+ data: {
+ success: true,
+ data: [{ hashtag: '#Movies', tweetsCount: 2000, category: 'Entertainment' }],
+ },
+ isLoading: false,
+ isError: false,
+ error: null,
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: true,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'success',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({
+ success: true,
+ data: [{ hashtag: '#Movies', tweetsCount: 2000, category: 'Entertainment' }],
+ }),
+ });
+
+ const { getByTestId, getByText } = render( );
+
+ expect(getByTestId('entertainment-list')).toBeTruthy();
+ expect(getByText('#Movies')).toBeTruthy();
+ });
+
+ it('renders empty message when no data is available', () => {
+ jest.spyOn(useEntertainmentTrendsModule, 'useEntertainmentTrends').mockReturnValue({
+ data: { success: true, data: [] },
+ isLoading: false,
+ isError: false,
+ error: null,
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: true,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'success',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByText } = render( );
+
+ expect(getByText('No trending entertainment available.')).toBeTruthy();
+ });
+
+ it('renders error message when there is an error', () => {
+ jest.spyOn(useEntertainmentTrendsModule, 'useEntertainmentTrends').mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new Error('Failed to load trending entertainment.'),
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: false,
+ isLoadingError: true,
+ isRefetchError: false,
+ status: 'error',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 1,
+ failureReason: new Error('Failed to load trending entertainment.'),
+ errorUpdateCount: 1,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByText } = render( );
+
+ expect(getByText('Failed to load trending entertainment.')).toBeTruthy();
+ });
+
+ it('renders loading state when data is loading', () => {
+ jest.spyOn(useEntertainmentTrendsModule, 'useEntertainmentTrends').mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ error: null,
+ isFetching: true,
+ refetch: jest.fn(),
+ isPending: true,
+ isSuccess: false,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'pending',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: false,
+ isFetchedAfterMount: false,
+ isStale: true,
+ isInitialLoading: true,
+ isPaused: false,
+ isRefetching: true,
+ isEnabled: true,
+ fetchStatus: 'fetching',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByTestId } = render( );
+
+ expect(getByTestId('loading-spinner')).toBeTruthy();
+ });
+});
diff --git a/src/__tests__/screens/explore/ForYouExploreScreen.test.tsx b/src/__tests__/screens/explore/ForYouExploreScreen.test.tsx
index bf797c735..efede2c55 100644
--- a/src/__tests__/screens/explore/ForYouExploreScreen.test.tsx
+++ b/src/__tests__/screens/explore/ForYouExploreScreen.test.tsx
@@ -1,3 +1,4 @@
+import { useNavigation } from '@react-navigation/native';
import { render } from '@testing-library/react-native';
import * as useForYouModule from '@/hooks/explore/useForYou';
@@ -5,13 +6,22 @@ import * as useTrendingModule from '@/hooks/explore/useTrending';
import * as drawerModule from '@/hooks/navigation/useDrawerSwipe';
import * as useFeedModule from '@/hooks/useFeed';
import * as themeModule from '@/hooks/useTheme';
-import ForYouExploreScreen from '@/screens/explore/ForYouExploreScreen';
+import ForYouExploreScreen, { Block, getItemType } from '@/screens/explore/ForYouExploreScreen';
+import { ROOT, TWEET } from '@/utils/navigation/routeNames';
import type { Trend } from '@/types/explore';
import type { Tweet } from '@/types/tweet';
jest.mock('@/navigation/navigationRef', () => ({
push: jest.fn(),
+ navigationRef: {
+ isReady: jest.fn(() => false),
+ getCurrentRoute: jest.fn(() => null),
+ navigate: jest.fn(),
+ dispatch: jest.fn(),
+ goBack: jest.fn(),
+ current: null,
+ },
}));
jest.mock(
@@ -29,16 +39,11 @@ jest.mock('@shopify/flash-list', () => {
ViewLocal,
{
testID: props.testID ?? 'mock-flashlist',
- onEndReached: props.onEndReached, // Pass the onEndReached prop
+ onEndReached: props.onEndReached,
+ refreshControl: props.refreshControl,
},
- props.ListHeaderComponent
- ? ReactLocal.createElement(
- ViewLocal,
- { testID: 'mock-list-header' },
- props.ListHeaderComponent
- )
- : null,
- ...(Array.isArray(props.data)
+ props.ListHeaderComponent ? ReactLocal.createElement(props.ListHeaderComponent) : null,
+ props.data
? props.data.map((item: unknown, index: number) => {
const info = { item, index } as Parameters>[0];
return ReactLocal.createElement(
@@ -47,21 +52,25 @@ jest.mock('@shopify/flash-list', () => {
props.renderItem ? props.renderItem(info) : null
);
})
- : []),
- props.ListFooterComponent ?? null
+ : [],
+ props.ListFooterComponent ? ReactLocal.createElement(props.ListFooterComponent) : null
),
};
});
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({
- navigate: jest.fn(),
- dispatch: jest.fn(),
- goBack: jest.fn(),
- replace: jest.fn(),
- setParams: jest.fn(),
- }),
-}));
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useNavigation: jest.fn(() => ({
+ navigate: jest.fn(),
+ dispatch: jest.fn(),
+ goBack: jest.fn(),
+ replace: jest.fn(),
+ setParams: jest.fn(),
+ })),
+ };
+});
jest.mock('@/components/ui/Tweet', () => {
const ReactLocal = jest.requireActual('react');
@@ -69,6 +78,7 @@ jest.mock('@/components/ui/Tweet', () => {
return (props: {
tweet?: { content: string };
onPressTweet?: (id: string) => void;
+ onPressAuthor?: (username: string) => void;
onLongPressRetweet?: (id: string) => void;
onLongPressLike?: (id: string) => void;
}) =>
@@ -76,7 +86,8 @@ jest.mock('@/components/ui/Tweet', () => {
ViewLocal,
{
testID: 'mock-tweet',
- onPressTweet: props.onPressTweet,
+ onPressTweet: props.onPressTweet || (() => {}),
+ onPressAuthor: props.onPressAuthor || (() => {}),
onLongPressRetweet: props.onLongPressRetweet,
onLongPressLike: props.onLongPressLike,
},
@@ -84,8 +95,8 @@ jest.mock('@/components/ui/Tweet', () => {
);
});
-describe('ForYouExploreScreen (unit tests)', () => {
- const sampleTrend: Trend = { hashtag: 'react', tweetCount: 100, category: 'tech' };
+describe('For you explore screen', () => {
+ const sampleTrend: Trend = { hashtag: 'react', tweetsCount: 100, category: 'tech' };
const sampleTweet: Tweet = {
id: 't1',
content: 'hello',
@@ -93,6 +104,13 @@ describe('ForYouExploreScreen (unit tests)', () => {
username: 'u1',
displayName: 'u1',
avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
createdAt: '2023-01-01',
replyCount: 0,
@@ -117,12 +135,17 @@ describe('ForYouExploreScreen (unit tests)', () => {
data: { success: true, data: [sampleTrend] },
isLoading: false,
isSuccess: true,
- } as ReturnType);
+ refetch: jest.fn(),
+ isError: false,
+ error: null,
+ isRefetching: false,
+ } as unknown as ReturnType);
jest.spyOn(useForYouModule, 'useForYouCategories').mockReturnValue({
data: { data: { categories: [] } },
isLoading: false,
isSuccess: true,
+ refetch: jest.fn(),
} as unknown as ReturnType);
jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
@@ -175,7 +198,7 @@ describe('ForYouExploreScreen (unit tests)', () => {
it('renders the trending section correctly', () => {
const { getByText } = render( );
- expect(getByText('#react')).toBeTruthy();
+ expect(getByText('react')).toBeTruthy();
});
it('handles loadMore callback without crashing', () => {
@@ -255,4 +278,489 @@ describe('ForYouExploreScreen (unit tests)', () => {
expect(fetchNextPageMock).toHaveBeenCalled();
});
+
+ it('handles handleOpenTweetDetail with empty tweetId', () => {
+ const { getByTestId } = render( );
+ const tweet = getByTestId('mock-tweet');
+ tweet.props.onPressTweet('');
+ expect(tweet).toBeTruthy(); // Ensure no crash
+ });
+
+ it('handles handleLongPressLike', () => {
+ const { getByTestId } = render( );
+ const tweet = getByTestId('mock-tweet');
+ tweet.props.onLongPressLike('t1');
+ expect(tweet).toBeTruthy();
+ });
+
+ it('handles handleLongPressRetweet', () => {
+ const { getByTestId } = render( );
+ const tweet = getByTestId('mock-tweet');
+ tweet.props.onLongPressRetweet('t1');
+ expect(tweet).toBeTruthy();
+ });
+
+ it('renders error state for trending', () => {
+ jest.spyOn(useTrendingModule, 'useTrending').mockReturnValue({
+ error: new Error('Failed to fetch trends'),
+ isLoading: false,
+ isRefetching: false,
+ data: null,
+ } as unknown as ReturnType);
+
+ const { getByText } = render( );
+ expect(getByText('Failed to load trends')).toBeTruthy();
+ });
+
+ it('renders ListFooterComponent when no more pages', () => {
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ data: {
+ pages: [{ data: [] }],
+ },
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType);
+
+ const { getByText } = render( );
+ expect(getByText("You're all caught up")).toBeTruthy();
+ });
+
+ it('handles handleOpenTweetDetail with invalid tweetId', () => {
+ const { getByTestId } = render( );
+ const tweet = getByTestId('mock-tweet');
+ tweet.props.onPressTweet(' '); // Simulate invalid tweetId
+ expect(tweet).toBeTruthy(); // Ensure no crash
+ });
+
+ it('handles handleLongPressLike with valid tweetId', () => {
+ const { getByTestId } = render( );
+ const tweet = getByTestId('mock-tweet');
+ tweet.props.onLongPressLike('t1');
+ expect(tweet).toBeTruthy(); // Ensure no crash
+ });
+
+ it('handles handleLongPressRetweet with valid tweetId', () => {
+ const { getByTestId } = render( );
+ const tweet = getByTestId('mock-tweet');
+ tweet.props.onLongPressRetweet('t1');
+ expect(tweet).toBeTruthy(); // Ensure no crash
+ });
+
+ it('navigates to the correct profile on goToProfile', () => {
+ const pushMock = jest.fn();
+ (useNavigation as jest.Mock).mockReturnValue({
+ push: pushMock,
+ });
+
+ const { getByTestId } = render( );
+ const tweet = getByTestId('mock-tweet');
+ tweet.props.onPressAuthor('testUser');
+
+ expect(pushMock).toHaveBeenCalledWith('Profile', {
+ screen: 'UserProfile',
+ params: { username: 'testUser' },
+ });
+ });
+
+ it('navigates to Likes tab on handleLongPressLike', () => {
+ const navigateMock = jest.fn();
+ jest.spyOn({ useNavigation }, 'useNavigation').mockReturnValue({
+ navigate: navigateMock,
+ });
+
+ const { getByTestId } = render( );
+ const tweet = getByTestId('mock-tweet');
+ tweet.props.onLongPressLike('tweetId1');
+
+ expect(navigateMock).toHaveBeenCalledWith(ROOT.TWEET, {
+ screen: TWEET.ACTIVITY,
+ params: { tweetId: 'tweetId1', initialTab: 'Likes' },
+ });
+ });
+
+ it('does not render tweet items when no tweets are available', () => {
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ data: {
+ pages: [{ data: [] }],
+ },
+ isLoading: false,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType);
+
+ const { queryByText } = render( );
+ expect(queryByText('hello')).toBeNull();
+ });
+
+ it('navigates to Reposts tab on handleLongPressRetweet', () => {
+ const navigateMock = jest.fn();
+ jest.spyOn({ useNavigation }, 'useNavigation').mockReturnValue({
+ navigate: navigateMock,
+ });
+
+ const { getByTestId } = render( );
+ const tweet = getByTestId('mock-tweet');
+ tweet.props.onLongPressRetweet('tweetId2');
+
+ expect(navigateMock).toHaveBeenCalledWith(ROOT.TWEET, {
+ screen: TWEET.ACTIVITY,
+ params: { tweetId: 'tweetId2', initialTab: 'Reposts' },
+ });
+ });
+
+ it('renders the ListEmptyComponent with error message when feedResult has error', () => {
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ isLoading: false,
+ isRefetching: false,
+ error: new Error('Failed to fetch feed'),
+ data: null,
+ } as unknown as ReturnType);
+
+ const { getByText } = render( );
+ expect(getByText('Failed to load more posts')).toBeTruthy();
+ });
+
+ it('renders the ListFooterComponent when there are no more pages', () => {
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ data: {
+ pages: [{ data: [] }],
+ },
+ } as unknown as ReturnType);
+
+ const { getByText } = render( );
+ expect(getByText("You're all caught up")).toBeTruthy();
+ });
+
+ it('does not call loadMore if isFetchingNextPage is true', () => {
+ const fetchNextPageMock = jest.fn();
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ hasNextPage: true,
+ isFetchingNextPage: true,
+ fetchNextPage: fetchNextPageMock,
+ } as unknown as ReturnType);
+
+ const { getByTestId } = render( );
+ const flashList = getByTestId('for-you-explore-flashlist');
+ flashList.props.onEndReached();
+
+ expect(fetchNextPageMock).not.toHaveBeenCalled();
+ });
+
+ it('renders the RefreshControl when isRefetching is true', () => {
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ isRefetching: true,
+ refetch: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByTestId } = render( );
+ const flashList = getByTestId('for-you-explore-flashlist');
+ expect(flashList.props.refreshControl).toBeTruthy();
+ });
+
+ it('handles loadMore correctly when hasNextPage is false', () => {
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ fetchNextPage: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByTestId } = render( );
+ const flashList = getByTestId('for-you-explore-flashlist');
+ flashList.props.onEndReached();
+ expect(flashList).toBeTruthy();
+ });
+
+ it('renders the ListEmptyComponent with loading state', () => {
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ isLoading: true,
+ data: null,
+ } as unknown as ReturnType);
+
+ const { getByTestId } = render( );
+ expect(getByTestId('loading-spinner')).toBeTruthy();
+ });
+
+ it('does not render the trending section when data is unavailable', () => {
+ jest.spyOn(useTrendingModule, 'useTrending').mockReturnValue({
+ data: null,
+ isLoading: false,
+ isSuccess: false,
+ } as unknown as ReturnType);
+
+ const { queryByText } = render( );
+ expect(queryByText('#react')).toBeNull();
+ });
+
+ it('renders categories section when categories are available', () => {
+ jest.spyOn(useForYouModule, 'useForYouCategories').mockReturnValue({
+ data: { data: { categories: [{ id: '1', name: 'Category 1' }] } },
+ isLoading: false,
+ isSuccess: true,
+ } as unknown as ReturnType);
+
+ const { getByText } = render( );
+ expect(getByText('Tech · Trending')).toBeTruthy();
+ });
+
+ it('does not render categories section when categories are unavailable', () => {
+ jest.spyOn(useForYouModule, 'useForYouCategories').mockReturnValue({
+ data: { data: { categories: [] } },
+ isLoading: false,
+ isSuccess: true,
+ } as unknown as ReturnType);
+
+ const { queryByText } = render( );
+ expect(queryByText('Category 1')).toBeNull();
+ });
+
+ it('renders the tweets header when tweets are available', () => {
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ data: {
+ pages: [{ data: [sampleTweet] }],
+ },
+ isLoading: false,
+ hasNextPage: true,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType);
+
+ const { getByText } = render( );
+ expect(getByText('Posts For You')).toBeTruthy();
+ });
+
+ it('renders the tweet item correctly', () => {
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ data: {
+ pages: [{ data: [sampleTweet] }],
+ },
+ isLoading: false,
+ hasNextPage: true,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType);
+
+ const { getByText } = render( );
+ expect(getByText('hello')).toBeTruthy();
+ });
+
+ it('does not render tweet items when no tweets are available', () => {
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ data: {
+ pages: [{ data: [] }],
+ },
+ isLoading: false,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ } as unknown as ReturnType);
+
+ const { queryByText } = render( );
+ expect(queryByText('hello')).toBeNull();
+ });
+
+ it('returns correct item type for trending block', () => {
+ const trendingBlock: Block = { type: 'trending', key: 'trending-key' };
+ expect(getItemType(trendingBlock)).toBe('trending');
+ });
+
+ it('returns correct item type for categories block', () => {
+ const categoriesBlock: Block = { type: 'categories', key: 'categories-key' };
+ expect(getItemType(categoriesBlock)).toBe('categories');
+ });
+
+ it('returns correct item type for tweets-header block', () => {
+ const tweetsHeaderBlock: Block = { type: 'tweets-header', key: 'tweets-header-key' };
+ expect(getItemType(tweetsHeaderBlock)).toBe('tweets-header');
+ });
+
+ it('returns correct item type for tweet block with media and quote', () => {
+ const tweetBlock: Block = {
+ type: 'tweet',
+ key: 'tweet-key',
+ tweet: {
+ ...sampleTweet,
+ media: [{ type: 'IMAGE', url: 'url', altText: 'Sample image', width: 100, height: 100 }],
+ quotedTweet: sampleTweet,
+ },
+ };
+ expect(getItemType(tweetBlock)).toBe('media-1-quote');
+ });
+
+ it('returns correct item type for tweet block with media only', () => {
+ const tweetBlock: Block = {
+ type: 'tweet',
+ key: 'tweet-key',
+ tweet: {
+ id: '1',
+ content: 'Sample Tweet',
+ author: {
+ username: 'user1',
+ displayName: 'User 1',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
+ createdAt: '2023-01-01',
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 0,
+ isLiked: false,
+ isRetweeted: false,
+ replyToTweetId: null,
+ quoteToTweetId: null,
+ entities: { mentions: null, hashtags: null },
+ media: [
+ {
+ type: 'IMAGE',
+ url: 'https://example.com/image.jpg',
+ altText: 'An example image',
+ width: 800,
+ height: 600,
+ },
+ ],
+ },
+ };
+ expect(getItemType(tweetBlock)).toBe('media-1');
+ });
+
+ it('returns correct item type for tweet block with quote only', () => {
+ const tweetBlock: Block = {
+ type: 'tweet',
+ key: 'tweet-key',
+ tweet: {
+ id: '1',
+ content: 'Sample Tweet',
+ author: {
+ username: 'user1',
+ displayName: 'User 1',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
+ createdAt: '2023-01-01',
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 0,
+ isLiked: false,
+ isRetweeted: false,
+ entities: { mentions: null, hashtags: null },
+ replyToTweetId: null,
+ quoteToTweetId: null,
+ media: null,
+ quotedTweet: {
+ id: '2',
+ author: {
+ username: 'user2',
+ displayName: 'User 2',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
+ content: 'Quoted Tweet',
+ createdAt: '2023-01-02',
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 0,
+ isLiked: false,
+ isRetweeted: false,
+ entities: { mentions: null, hashtags: null },
+ media: null,
+ },
+ },
+ };
+ expect(getItemType(tweetBlock)).toBe('quote');
+ });
+
+ it('returns correct item type for tweet block with text only', () => {
+ const tweetBlock: Block = {
+ type: 'tweet',
+ key: 'tweet-key',
+ tweet: {
+ id: '1',
+ content: 'Sample Tweet',
+ author: {
+ username: 'user1',
+ displayName: 'User 1',
+ avatarUrl: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ },
+ createdAt: '2023-01-01',
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 0,
+ isLiked: false,
+ isRetweeted: false,
+ entities: { mentions: null, hashtags: null },
+ replyToTweetId: null,
+ quoteToTweetId: null,
+ media: null,
+ quotedTweet: null,
+ },
+ };
+ expect(getItemType(tweetBlock)).toBe('text');
+ });
+
+ it('calls loadMore only when conditions are met', () => {
+ const fetchNextPageMock = jest.fn();
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ hasNextPage: true,
+ isFetchingNextPage: false,
+ fetchNextPage: fetchNextPageMock,
+ } as unknown as ReturnType);
+
+ const { getByTestId } = render( );
+ const flashList = getByTestId('for-you-explore-flashlist');
+ flashList.props.onEndReached();
+
+ expect(fetchNextPageMock).toHaveBeenCalled();
+ });
+
+ it('does not call loadMore when conditions are not met', () => {
+ const fetchNextPageMock = jest.fn();
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ hasNextPage: false,
+ isFetchingNextPage: true,
+ fetchNextPage: fetchNextPageMock,
+ } as unknown as ReturnType);
+
+ const { getByTestId } = render( );
+ const flashList = getByTestId('for-you-explore-flashlist');
+ flashList.props.onEndReached();
+
+ expect(fetchNextPageMock).not.toHaveBeenCalled();
+ });
+
+ it('handles handleRefresh correctly', () => {
+ const refetchMock = jest.fn();
+ jest.spyOn(useFeedModule, 'useFeed').mockReturnValue({
+ refetch: refetchMock,
+ isRefetching: false,
+ } as unknown as ReturnType);
+
+ const { getByTestId } = render( );
+ const flashList = getByTestId('for-you-explore-flashlist');
+ flashList.props.refreshControl.props.onRefresh();
+
+ expect(refetchMock).toHaveBeenCalled();
+ });
});
diff --git a/src/__tests__/screens/explore/NewsScreen.test.tsx b/src/__tests__/screens/explore/NewsScreen.test.tsx
new file mode 100644
index 000000000..d93aff37a
--- /dev/null
+++ b/src/__tests__/screens/explore/NewsScreen.test.tsx
@@ -0,0 +1,163 @@
+import { render } from '@testing-library/react-native';
+
+import * as useNewsTrendsModule from '@/hooks/explore/useNewsTrends';
+import NewsScreen from '@/screens/explore/NewsScreen';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({
+ theme: 'light',
+ }),
+}));
+
+describe('NewsScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the TrendsList with data', () => {
+ jest.spyOn(useNewsTrendsModule, 'useNewsTrends').mockReturnValue({
+ data: {
+ success: true,
+ data: [{ hashtag: '#BreakingNews', tweetsCount: 500, category: 'News' }],
+ },
+ isLoading: false,
+ isError: false,
+ error: null,
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: true,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'success',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({
+ success: true,
+ data: [{ hashtag: '#BreakingNews', tweetsCount: 500, category: 'News' }],
+ }),
+ });
+
+ const { getByTestId, getByText } = render( );
+
+ expect(getByTestId('news-list')).toBeTruthy();
+ expect(getByText('#BreakingNews')).toBeTruthy();
+ });
+
+ it('renders empty message when no data is available', () => {
+ jest.spyOn(useNewsTrendsModule, 'useNewsTrends').mockReturnValue({
+ data: { success: true, data: [] },
+ isLoading: false,
+ isError: false,
+ error: null,
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: true,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'success',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByText } = render( );
+
+ expect(getByText('No trending news available.')).toBeTruthy();
+ });
+
+ it('renders error message when there is an error', () => {
+ jest.spyOn(useNewsTrendsModule, 'useNewsTrends').mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new Error('Failed to load trending news.'),
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: false,
+ isLoadingError: true,
+ isRefetchError: false,
+ status: 'error',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 1,
+ failureReason: new Error('Failed to load trending news.'),
+ errorUpdateCount: 1,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByText } = render( );
+
+ expect(getByText('Failed to load trending news.')).toBeTruthy();
+ });
+
+ it('renders loading state when data is loading', () => {
+ jest.spyOn(useNewsTrendsModule, 'useNewsTrends').mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ error: null,
+ isFetching: true,
+ refetch: jest.fn(),
+ isPending: true,
+ isSuccess: false,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'pending',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: false,
+ isFetchedAfterMount: false,
+ isStale: true,
+ isInitialLoading: true,
+ isPaused: false,
+ isRefetching: true,
+ isEnabled: true,
+ fetchStatus: 'fetching',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByTestId } = render( );
+
+ expect(getByTestId('loading-spinner')).toBeTruthy();
+ });
+});
diff --git a/src/__tests__/screens/explore/SportsScreen.test.tsx b/src/__tests__/screens/explore/SportsScreen.test.tsx
new file mode 100644
index 000000000..5ebd73c80
--- /dev/null
+++ b/src/__tests__/screens/explore/SportsScreen.test.tsx
@@ -0,0 +1,163 @@
+import { render } from '@testing-library/react-native';
+
+import * as useSportsTrendsModule from '@/hooks/explore/useSportsTrends';
+import SportsScreen from '@/screens/explore/SportsScreen';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({
+ theme: 'light',
+ }),
+}));
+
+describe('SportsScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the TrendsList with data', () => {
+ jest.spyOn(useSportsTrendsModule, 'useSportsTrends').mockReturnValue({
+ data: {
+ success: true,
+ data: [{ hashtag: '#Football', tweetsCount: 1000, category: 'Sports' }],
+ },
+ isLoading: false,
+ isError: false,
+ error: null,
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: true,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'success',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({
+ success: true,
+ data: [{ hashtag: '#Football', tweetsCount: 1000, category: 'Sports' }],
+ }),
+ });
+
+ const { getByTestId, getByText } = render( );
+
+ expect(getByTestId('sports-list')).toBeTruthy();
+ expect(getByText('#Football')).toBeTruthy();
+ });
+
+ it('renders empty message when no data is available', () => {
+ jest.spyOn(useSportsTrendsModule, 'useSportsTrends').mockReturnValue({
+ data: { success: true, data: [] },
+ isLoading: false,
+ isError: false,
+ error: null,
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: true,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'success',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByText } = render( );
+
+ expect(getByText('No trending sports available.')).toBeTruthy();
+ });
+
+ it('renders error message when there is an error', () => {
+ jest.spyOn(useSportsTrendsModule, 'useSportsTrends').mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new Error('Failed to load trending sports.'),
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: false,
+ isLoadingError: true,
+ isRefetchError: false,
+ status: 'error',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 1,
+ failureReason: new Error('Failed to load trending sports.'),
+ errorUpdateCount: 1,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByText } = render( );
+
+ expect(getByText('Failed to load trending sports.')).toBeTruthy();
+ });
+
+ it('renders loading state when data is loading', () => {
+ jest.spyOn(useSportsTrendsModule, 'useSportsTrends').mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ error: null,
+ isFetching: true,
+ refetch: jest.fn(),
+ isPending: true,
+ isSuccess: false,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'pending',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: false,
+ isFetchedAfterMount: false,
+ isStale: true,
+ isInitialLoading: true,
+ isPaused: false,
+ isRefetching: true,
+ isEnabled: true,
+ fetchStatus: 'fetching',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByTestId } = render( );
+
+ expect(getByTestId('loading-spinner')).toBeTruthy();
+ });
+});
diff --git a/src/__tests__/screens/explore/TrendingScreen.test.tsx b/src/__tests__/screens/explore/TrendingScreen.test.tsx
new file mode 100644
index 000000000..ca8191115
--- /dev/null
+++ b/src/__tests__/screens/explore/TrendingScreen.test.tsx
@@ -0,0 +1,160 @@
+import { render } from '@testing-library/react-native';
+
+import * as useTrendingModule from '@/hooks/explore/useTrending';
+import TrendingScreen from '@/screens/explore/TrendingScreen';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({
+ theme: 'light',
+ }),
+}));
+
+describe('TrendingScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the TrendsList with data', () => {
+ jest.spyOn(useTrendingModule, 'useTrending').mockReturnValue({
+ data: { success: true, data: [{ hashtag: '#React', tweetsCount: 100, category: 'Tech' }] },
+ isLoading: false,
+ isError: false,
+ error: null,
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: true,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'success',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({
+ success: true,
+ data: [{ hashtag: '#React', tweetsCount: 100, category: 'Tech' }],
+ }),
+ });
+
+ const { getByTestId, getByText } = render( );
+
+ expect(getByTestId('trending-list')).toBeTruthy();
+ expect(getByText('#React')).toBeTruthy();
+ });
+
+ it('renders empty message when no data is available', () => {
+ jest.spyOn(useTrendingModule, 'useTrending').mockReturnValue({
+ data: { success: true, data: [] },
+ isLoading: false,
+ isError: false,
+ error: null,
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: true,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'success',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByText } = render( );
+
+ expect(getByText('No trending topics available.')).toBeTruthy();
+ });
+
+ it('renders error message when there is an error', () => {
+ jest.spyOn(useTrendingModule, 'useTrending').mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new Error('Failed to load trending topics.'),
+ isFetching: false,
+ refetch: jest.fn(),
+ isPending: false,
+ isSuccess: false,
+ isLoadingError: true,
+ isRefetchError: false,
+ status: 'error',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 1,
+ failureReason: new Error('Failed to load trending topics.'),
+ errorUpdateCount: 1,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isStale: false,
+ isInitialLoading: false,
+ isPaused: false,
+ isRefetching: false,
+ isEnabled: true,
+ fetchStatus: 'idle',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByText } = render( );
+
+ expect(getByText('Failed to load trending topics.')).toBeTruthy();
+ });
+
+ it('renders loading state when data is loading', () => {
+ jest.spyOn(useTrendingModule, 'useTrending').mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ error: null,
+ isFetching: true,
+ refetch: jest.fn(),
+ isPending: true,
+ isSuccess: false,
+ isLoadingError: false,
+ isRefetchError: false,
+ status: 'pending',
+ dataUpdatedAt: Date.now(),
+ errorUpdatedAt: Date.now(),
+ isPlaceholderData: false,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ isFetched: false,
+ isFetchedAfterMount: false,
+ isStale: true,
+ isInitialLoading: true,
+ isPaused: false,
+ isRefetching: true,
+ isEnabled: true,
+ fetchStatus: 'fetching',
+ promise: Promise.resolve({ success: true, data: [] }),
+ });
+
+ const { getByTestId } = render( );
+
+ expect(getByTestId('loading-spinner')).toBeTruthy();
+ });
+});
diff --git a/src/__tests__/screens/home/FollowingScreen.test.tsx b/src/__tests__/screens/home/FollowingScreen.test.tsx
index dd9c65897..66e6c793d 100644
--- a/src/__tests__/screens/home/FollowingScreen.test.tsx
+++ b/src/__tests__/screens/home/FollowingScreen.test.tsx
@@ -5,6 +5,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ThemeProvider } from '@/hooks/useTheme';
import FollowingScreen from '@/screens/home/FollowingScreen';
import * as timelineService from '@/services/timeline';
+import { useTimelineStore } from '@/stores/timelineStore';
jest.mock('@/services/timeline', () => ({
getFollowingFeed: jest.fn(),
@@ -61,6 +62,16 @@ jest.mock('@/components/ui/AppText', () => ({
default: 'AppText',
}));
+jest.mock('@/components/ui/NewTweetsIndicator', () => ({
+ __esModule: true,
+ default: jest.fn((props: { testID?: string }) => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const View = require('react-native').View;
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ return require('react').createElement(View, { testID: props.testID });
+ }),
+}));
+
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
const mockRootNavigation = { navigate: jest.fn() };
@@ -75,6 +86,7 @@ jest.mock('@react-navigation/native', () => {
goBack: jest.fn(),
getParent: () => mockBottomTabsNavigation,
}),
+ useScrollToTop: jest.fn(),
};
});
@@ -114,7 +126,20 @@ describe('FollowingScreen', () => {
const mockTweets = [
{
id: '1',
- author: { username: 'user1', displayName: 'User 1', avatarUrl: '' },
+ author: {
+ username: 'user1',
+ displayName: 'User 1',
+ avatarUrl: '',
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
+ bio: null,
+ bioEntities: null,
+ },
content: 'Test tweet 1',
createdAt: '2023-01-01T00:00:00Z',
replyCount: 1,
@@ -132,6 +157,7 @@ describe('FollowingScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
+ useTimelineStore.setState({ followingNewTweetAuthors: null });
mockGetFollowingFeed.mockResolvedValue({
success: true,
message: 'Success',
@@ -223,4 +249,89 @@ describe('FollowingScreen', () => {
expect(toJSON()).toBeTruthy();
});
+
+ it('does not show NewTweetsIndicator when followingNewTweetAuthors is null', async () => {
+ useTimelineStore.setState({ followingNewTweetAuthors: null });
+
+ const { queryByTestId } = renderWithProviders( );
+
+ await waitFor(() => {
+ expect(mockGetFollowingFeed).toHaveBeenCalled();
+ });
+
+ expect(queryByTestId('new-tweets-indicator')).toBeNull();
+ });
+
+ it('does not render NewTweetsIndicator when followingNewTweetAuthors is null', async () => {
+ useTimelineStore.setState({ followingNewTweetAuthors: null });
+
+ const { queryByTestId } = renderWithProviders( );
+
+ await waitFor(() => {
+ expect(mockGetFollowingFeed).toHaveBeenCalled();
+ });
+
+ expect(queryByTestId('new-tweets-indicator')).toBeNull();
+ });
+
+ it('does not render NewTweetsIndicator when followingNewTweetAuthors is empty', async () => {
+ useTimelineStore.setState({ followingNewTweetAuthors: [] });
+
+ const { queryByTestId } = renderWithProviders( );
+
+ await waitFor(() => {
+ expect(mockGetFollowingFeed).toHaveBeenCalled();
+ });
+
+ expect(queryByTestId('new-tweets-indicator')).toBeNull();
+ });
+
+ it('renders NewTweetsIndicator when followingNewTweetAuthors has values', async () => {
+ useTimelineStore.setState({
+ followingNewTweetAuthors: [
+ 'https://example.com/avatar1.jpg',
+ 'https://example.com/avatar2.jpg',
+ ],
+ });
+
+ const { getByTestId } = renderWithProviders( );
+
+ await waitFor(() => {
+ expect(mockGetFollowingFeed).toHaveBeenCalled();
+ });
+
+ expect(getByTestId('new-tweets-indicator')).toBeTruthy();
+ });
+
+ it('renders NewTweetsIndicator with single author', async () => {
+ useTimelineStore.setState({
+ followingNewTweetAuthors: ['https://example.com/avatar1.jpg'],
+ });
+
+ const { getByTestId } = renderWithProviders( );
+
+ await waitFor(() => {
+ expect(mockGetFollowingFeed).toHaveBeenCalled();
+ });
+
+ expect(getByTestId('new-tweets-indicator')).toBeTruthy();
+ });
+
+ it('renders correctly with multiple authors', async () => {
+ useTimelineStore.setState({
+ followingNewTweetAuthors: [
+ 'https://example.com/avatar1.jpg',
+ 'https://example.com/avatar2.jpg',
+ 'https://example.com/avatar3.jpg',
+ ],
+ });
+
+ const { getByTestId } = renderWithProviders( );
+
+ await waitFor(() => {
+ expect(mockGetFollowingFeed).toHaveBeenCalled();
+ });
+
+ expect(getByTestId('new-tweets-indicator')).toBeTruthy();
+ });
});
diff --git a/src/__tests__/screens/legal/LegalScreens.test.tsx b/src/__tests__/screens/legal/LegalScreens.test.tsx
new file mode 100644
index 000000000..d420cc5e4
--- /dev/null
+++ b/src/__tests__/screens/legal/LegalScreens.test.tsx
@@ -0,0 +1,106 @@
+import type { ReactElement } from 'react';
+
+import { render } from '@testing-library/react-native';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
+
+import { ThemeProvider } from '@/hooks/useTheme';
+import PrivacyPolicyScreen from '@/screens/legal/PrivacyPolicyScreen';
+import TermsOfServiceScreen from '@/screens/legal/TermsOfServiceScreen';
+
+const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+const initialSafeAreaMetrics = {
+ frame: { x: 0, y: 0, width: 390, height: 844 },
+ insets: { top: 0, left: 0, right: 0, bottom: 0 },
+};
+
+const renderWithProviders = (component: ReactElement) =>
+ render(
+
+ {component}
+
+ );
+
+describe('PrivacyPolicyScreen', () => {
+ it('renders every major privacy section', () => {
+ const { getByText } = renderWithProviders( );
+
+ const sections = [
+ '1. Information We Collect',
+ '2. How We Use Your Information',
+ '3. Information Sharing',
+ '4. Cookies and Tracking',
+ '5. Data Security',
+ '6. Your Rights',
+ '7. Data Retention',
+ "8. Children's Privacy",
+ '9. Changes to This Policy',
+ '10. Contact Us',
+ ];
+
+ sections.forEach((title) => {
+ expect(getByText(title)).toBeTruthy();
+ });
+
+ expect(getByText('Last Updated: December 15, 2025')).toBeTruthy();
+ });
+
+ it('lists the specific privacy guarantees and user rights', () => {
+ const { getByText } = renderWithProviders( );
+
+ const bulletSnippets = [
+ 'Account information (name, email, username, birthdate)',
+ 'Provide, maintain, and improve our services',
+ 'Communicate with you about updates and notifications',
+ 'Ensure security and prevent fraud',
+ 'Access your personal data',
+ 'Request deletion of your account',
+ 'If you have questions about this Privacy Policy, please contact us',
+ ];
+
+ bulletSnippets.forEach((snippet) => {
+ expect(getByText(new RegExp(escapeRegExp(snippet)))).toBeTruthy();
+ });
+ });
+});
+
+describe('TermsOfServiceScreen', () => {
+ it('renders every major terms section', () => {
+ const { getByText } = renderWithProviders( );
+
+ const sections = [
+ '1. Acceptance of Terms',
+ '2. Eligibility',
+ '3. Account Registration',
+ '4. User Content',
+ '5. Prohibited Conduct',
+ '6. Termination',
+ '7. Disclaimer of Warranties',
+ '8. Changes to Terms',
+ '9. Contact Us',
+ ];
+
+ sections.forEach((title) => {
+ expect(getByText(title)).toBeTruthy();
+ });
+
+ expect(getByText('Last Updated: December 15, 2025')).toBeTruthy();
+ });
+
+ it('details the prohibited conduct list to the user', () => {
+ const { getByText } = renderWithProviders( );
+
+ const bulletSnippets = [
+ 'Spam or unsolicited advertising',
+ 'Harassment, bullying, or threatening behavior',
+ 'Impersonating other users or entities',
+ 'Posting illegal or harmful content',
+ 'Distributing malware or viruses',
+ 'Scraping or data mining without permission',
+ ];
+
+ bulletSnippets.forEach((snippet) => {
+ expect(getByText(new RegExp(escapeRegExp(snippet)))).toBeTruthy();
+ });
+ });
+});
diff --git a/src/__tests__/screens/messages/ChatScreen.test.tsx b/src/__tests__/screens/messages/ChatScreen.test.tsx
index c0d4b7f9e..ebb0a7499 100644
--- a/src/__tests__/screens/messages/ChatScreen.test.tsx
+++ b/src/__tests__/screens/messages/ChatScreen.test.tsx
@@ -42,6 +42,18 @@ jest.mock('@react-navigation/native', () => {
jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'dark' as const }) }));
+jest.mock('@/navigation/navigationRef', () => ({
+ navigationRef: {
+ isReady: jest.fn(() => true),
+ goBack: jest.fn(),
+ navigate: jest.fn(),
+ dispatch: jest.fn(),
+ },
+ resetToStart: jest.fn(),
+ navigate: jest.fn(),
+ push: jest.fn(),
+}));
+
jest.mock('@/stores/dmStore', () => ({
useDmStore: (selector: (state: any) => any) =>
selector({
@@ -399,4 +411,154 @@ describe('ChatScreen', () => {
rendered.unmount();
expect(mockSetActiveConversationId).toHaveBeenCalledWith(null);
});
+
+ it('displays typing indicator when other user is typing', async () => {
+ const { queryByTestId, getByPlaceholderText } = renderWithClient();
+
+ await waitFor(() => getByPlaceholderText('Start a message'));
+
+ // Initially no typing indicator
+ expect(queryByTestId('typing-indicator')).toBeNull();
+
+ // Simulate other user typing
+ act(() => {
+ triggerSocket('user_typing', {
+ conversationId: 'conv1',
+ username: 'other',
+ });
+ });
+
+ await waitFor(() => {
+ expect(queryByTestId('typing-indicator')).toBeTruthy();
+ });
+ });
+
+ it('hides typing indicator when other user stops typing', async () => {
+ const { queryByTestId, getByPlaceholderText } = renderWithClient();
+
+ await waitFor(() => getByPlaceholderText('Start a message'));
+
+ // Start typing
+ act(() => {
+ triggerSocket('user_typing', {
+ conversationId: 'conv1',
+ username: 'other',
+ });
+ });
+
+ await waitFor(() => expect(queryByTestId('typing-indicator')).toBeTruthy());
+
+ // Stop typing
+ act(() => {
+ triggerSocket('user_typing_stop', {
+ conversationId: 'conv1',
+ username: 'other',
+ });
+ });
+
+ await waitFor(() => {
+ expect(queryByTestId('typing-indicator')).toBeNull();
+ });
+ });
+
+ it('emits typing_start when user types in input', async () => {
+ const socket = require('@/services/socket');
+ const { getByPlaceholderText } = renderWithClient();
+
+ await waitFor(() => getByPlaceholderText('Start a message'));
+
+ const input = getByPlaceholderText('Start a message');
+ fireEvent.changeText(input, 'H');
+
+ expect(socket.emitTypingStart).toHaveBeenCalledWith('conv1');
+ });
+
+ it('emits typing_stop after user stops typing', async () => {
+ jest.useFakeTimers();
+ const socket = require('@/services/socket');
+ const { getByPlaceholderText } = renderWithClient();
+
+ await waitFor(() => getByPlaceholderText('Start a message'));
+
+ const input = getByPlaceholderText('Start a message');
+
+ // Type first character
+ fireEvent.changeText(input, 'H');
+ expect(socket.emitTypingStart).toHaveBeenCalledWith('conv1');
+
+ // Clear previous calls
+ socket.emitTypingStop.mockClear();
+
+ // Type more
+ fireEvent.changeText(input, 'He');
+
+ // Fast-forward 5 seconds
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+
+ expect(socket.emitTypingStop).toHaveBeenCalledWith('conv1');
+
+ jest.useRealTimers();
+ });
+
+ it('stops emitting typing events after user sends message', async () => {
+ jest.useFakeTimers();
+ const socket = require('@/services/socket');
+ const { getByPlaceholderText, getByTestId } = renderWithClient();
+
+ await waitFor(() => getByPlaceholderText('Start a message'));
+
+ const input = getByPlaceholderText('Start a message');
+
+ // Type
+ fireEvent.changeText(input, 'Test message');
+ expect(socket.emitTypingStart).toHaveBeenCalledWith('conv1');
+
+ // Send
+ await waitFor(() => expect(getByTestId('send-button')).toBeTruthy());
+
+ socket.emitTypingStop.mockClear();
+ fireEvent.press(getByTestId('send-button'));
+
+ expect(socket.emitTypingStop).toHaveBeenCalledWith('conv1');
+
+ jest.useRealTimers();
+ });
+
+ it('clears typing state on unmount', async () => {
+ jest.useFakeTimers();
+ const socket = require('@/services/socket');
+ const { getByPlaceholderText, unmount } = renderWithClient();
+
+ await waitFor(() => getByPlaceholderText('Start a message'));
+
+ const input = getByPlaceholderText('Start a message');
+ fireEvent.changeText(input, 'H');
+
+ socket.emitTypingStop.mockClear();
+
+ unmount();
+
+ expect(socket.emitTypingStop).toHaveBeenCalledWith('conv1');
+
+ jest.useRealTimers();
+ });
+
+ it('does not show typing indicator for current user', async () => {
+ const { queryByTestId, getByPlaceholderText } = renderWithClient();
+
+ await waitFor(() => getByPlaceholderText('Start a message'));
+
+ // Current user types (username: 'me')
+ act(() => {
+ triggerSocket('user_typing', {
+ conversationId: 'conv1',
+ username: 'me',
+ });
+ });
+
+ // Should not show typing indicator for self
+ expect(queryByTestId('typing-indicator')).toBeNull();
+ });
});
diff --git a/src/__tests__/screens/messages/MessageImageScreen.test.tsx b/src/__tests__/screens/messages/MessageImageScreen.test.tsx
new file mode 100644
index 000000000..b630ec714
--- /dev/null
+++ b/src/__tests__/screens/messages/MessageImageScreen.test.tsx
@@ -0,0 +1,252 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-require-imports */
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
+import * as FileSystem from 'expo-file-system/legacy';
+import * as MediaLibrary from 'expo-media-library';
+
+import MessageImageScreen from '@/screens/messages/MessageImageScreen';
+import { downloadImage, requestPermissions, saveImageToDevice } from '@/utils/profile/media';
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'dark' as const }),
+}));
+
+jest.mock('@expo/vector-icons', () => ({
+ Ionicons: 'Ionicons',
+}));
+
+jest.mock('@/components/ui/Button', () => {
+ const { Pressable, Text } = require('react-native');
+ return {
+ __esModule: true,
+ default: ({
+ title,
+ onPress,
+ variant,
+ }: {
+ title: string;
+ onPress: () => void;
+ variant?: string;
+ }) => (
+
+ {title}
+
+ ),
+ };
+});
+
+jest.mock('@/components/ui/Spinner', () => {
+ const { ActivityIndicator } = require('react-native');
+ return {
+ __esModule: true,
+ default: ({ size }: { size?: number | 'small' | 'large' }) => (
+
+ ),
+ };
+});
+
+jest.mock('@/utils/profile/media');
+const mockRequestPermissions = requestPermissions as jest.Mock;
+const mockDownloadImage = downloadImage as jest.Mock;
+const mockSaveImageToDevice = saveImageToDevice as jest.Mock;
+
+jest.mock('expo-file-system/legacy');
+jest.mock('expo-media-library');
+
+const mockNavigation = {
+ goBack: jest.fn(),
+};
+
+let mockRoute: any = {
+ params: { uri: 'https://example.com/test.jpg' },
+};
+
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return {
+ ...actual,
+ useNavigation: () => mockNavigation,
+ useRoute: () => mockRoute,
+ };
+});
+
+describe('MessageImageScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockRoute = {
+ params: {
+ uri: 'https://example.com/test.jpg',
+ },
+ };
+
+ mockRequestPermissions.mockResolvedValue(true);
+ mockDownloadImage.mockResolvedValue('file:///downloaded.jpg');
+ mockSaveImageToDevice.mockResolvedValue(undefined);
+
+ (MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
+ (MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue({});
+ (FileSystem.downloadAsync as jest.Mock).mockResolvedValue({
+ uri: 'file:///downloaded/file.jpg',
+ });
+ });
+
+ describe('Rendering', () => {
+ it('renders the image when a URI is provided', () => {
+ const { getByTestId } = render( );
+ expect(getByTestId('message-image')).toBeTruthy();
+ });
+
+ it('renders placeholder when no URI is provided', () => {
+ mockRoute.params.uri = null;
+
+ const { getByTestId } = render( );
+ expect(getByTestId('message-image-placeholder')).toBeTruthy();
+ });
+ });
+
+ describe('Navigation', () => {
+ it('navigates back when back button pressed', () => {
+ const { getByTestId } = render( );
+ fireEvent.press(getByTestId('back-button'));
+
+ expect(mockNavigation.goBack).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Menu Interaction', () => {
+ it('opens menu when menu button is pressed', () => {
+ const { getByTestId, getByText } = render( );
+ fireEvent.press(getByTestId('menu-button'));
+
+ expect(getByText('Save')).toBeTruthy();
+ });
+
+ it('closes menu when overlay requests close', () => {
+ const { getByTestId, getByText, queryByText } = render( );
+
+ fireEvent.press(getByTestId('menu-button'));
+ expect(getByText('Save')).toBeTruthy();
+
+ fireEvent(getByTestId('menu-overlay'), 'onRequestClose');
+ expect(queryByText('Save')).toBeNull();
+ });
+ });
+
+ describe('Save Functionality', () => {
+ it('requests permissions, downloads image, saves image', async () => {
+ const { getByTestId, getByText } = render( );
+
+ fireEvent.press(getByTestId('menu-button'));
+ fireEvent.press(getByText('Save'));
+
+ await waitFor(() => {
+ expect(mockRequestPermissions).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(mockDownloadImage).toHaveBeenCalledWith('https://example.com/test.jpg');
+ });
+
+ await waitFor(() => {
+ expect(mockSaveImageToDevice).toHaveBeenCalledWith('file:///downloaded.jpg');
+ });
+ });
+
+ it('shows saving overlay while saving', async () => {
+ mockDownloadImage.mockImplementation(
+ () => new Promise((res) => setTimeout(() => res('file:///downloaded.jpg'), 150))
+ );
+
+ const { getByTestId, getByText } = render( );
+
+ fireEvent.press(getByTestId('menu-button'));
+ fireEvent.press(getByText('Save'));
+
+ await waitFor(() => {
+ expect(getByText('Saving')).toBeTruthy();
+ });
+ });
+
+ it('does nothing when permissions denied', async () => {
+ mockRequestPermissions.mockResolvedValue(false);
+
+ const { getByTestId, getByText } = render( );
+
+ fireEvent.press(getByTestId('menu-button'));
+ fireEvent.press(getByText('Save'));
+
+ await waitFor(() => {
+ expect(mockDownloadImage).not.toHaveBeenCalled();
+ expect(mockSaveImageToDevice).not.toHaveBeenCalled();
+ });
+ });
+
+ it('handles download errors gracefully', async () => {
+ const spy = jest.spyOn(console, 'error').mockImplementation();
+ mockDownloadImage.mockRejectedValue(new Error('Download failed'));
+
+ const { getByTestId, getByText } = render( );
+ fireEvent.press(getByTestId('menu-button'));
+ fireEvent.press(getByText('Save'));
+
+ await waitFor(() =>
+ expect(spy).toHaveBeenCalledWith('Error saving image:', expect.any(Error))
+ );
+
+ spy.mockRestore();
+ });
+
+ it('handles saveToDevice errors gracefully', async () => {
+ const spy = jest.spyOn(console, 'error').mockImplementation();
+ mockSaveImageToDevice.mockRejectedValue(new Error('Save failed'));
+
+ const { getByTestId, getByText } = render( );
+ fireEvent.press(getByTestId('menu-button'));
+ fireEvent.press(getByText('Save'));
+
+ await waitFor(() =>
+ expect(spy).toHaveBeenCalledWith('Error saving image:', expect.any(Error))
+ );
+
+ spy.mockRestore();
+ });
+
+ it('does not save when downloadImage returns null', async () => {
+ mockDownloadImage.mockResolvedValue(null);
+
+ const { getByTestId, getByText } = render( );
+
+ fireEvent.press(getByTestId('menu-button'));
+ fireEvent.press(getByText('Save'));
+
+ await waitFor(() => {
+ expect(mockSaveImageToDevice).not.toHaveBeenCalled();
+ });
+ });
+
+ it('hides saving overlay when completed', async () => {
+ const { getByTestId, getByText, queryByText } = render( );
+
+ fireEvent.press(getByTestId('menu-button'));
+ fireEvent.press(getByText('Save'));
+
+ await waitFor(() => {
+ expect(mockSaveImageToDevice).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(queryByText('Saving')).toBeNull();
+ });
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('renders placeholder when route params missing', () => {
+ mockRoute.params = { uri: undefined };
+
+ const { getByTestId } = render( );
+ expect(getByTestId('message-image-placeholder')).toBeTruthy();
+ });
+ });
+});
diff --git a/src/__tests__/screens/messages/MessagesScreen.test.tsx b/src/__tests__/screens/messages/MessagesScreen.test.tsx
index 2e2713c29..9972d2081 100644
--- a/src/__tests__/screens/messages/MessagesScreen.test.tsx
+++ b/src/__tests__/screens/messages/MessagesScreen.test.tsx
@@ -117,7 +117,7 @@ describe('MessagesScreen', () => {
await waitFor(() => expect(getByText('Alice')).toBeTruthy());
- fireEvent.press(getByLabelText('New message'));
+ fireEvent.press(getByLabelText('new-message-button'));
await waitFor(() => expect(getByTestId('new-message-sheet')).toBeTruthy());
fireEvent.press(getByTestId('new-message-backdrop'));
diff --git a/src/__tests__/screens/profile/ProfileScreen.test.tsx b/src/__tests__/screens/profile/ProfileScreen.test.tsx
new file mode 100644
index 000000000..a50c6c737
--- /dev/null
+++ b/src/__tests__/screens/profile/ProfileScreen.test.tsx
@@ -0,0 +1,291 @@
+import React from 'react';
+
+import { useRoute } from '@react-navigation/native';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
+
+import { useProfile } from '@/hooks/profile/useProfile';
+import { ThemeProvider } from '@/hooks/useTheme';
+import ProfileScreen from '@/screens/profile/ProfileScreen';
+import { UserProfile } from '@/types/user';
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useRoute: jest.fn(),
+}));
+
+jest.mock('@/hooks/profile/useProfile');
+jest.mock('@/components/profile/ProfileHeader', () => 'ProfileHeader');
+jest.mock('@/navigation/profile/ProfileTabsNavigator', () => ({
+ ProfileTabsNavigator: () => null,
+}));
+
+const mockUseRoute = useRoute as jest.MockedFunction;
+const mockUseProfile = useProfile as jest.MockedFunction;
+
+const createMockProfile = (overrides?: Partial): UserProfile => ({
+ username: 'testuser',
+ displayName: 'Test User',
+ bio: 'Test bio',
+ bioEntities: null,
+ avatarUrl: 'https://example.com/avatar.png',
+ bannerUrl: null,
+ location: 'Test Location',
+ websiteUrl: null,
+ birthDate: null,
+ joinedAt: '2020-01-01',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ followingCount: 100,
+ followersCount: 200,
+ mutualsCount: 10,
+ mutualUsers: [],
+ ...overrides,
+});
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+});
+
+const Wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+describe('ProfileScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('shows invalid profile if username not found', () => {
+ mockUseRoute.mockReturnValue({
+ params: {},
+ } as ReturnType);
+
+ mockUseProfile.mockReturnValue({
+ data: undefined,
+ error: null,
+ isLoading: false,
+ refetch: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('Invalid profile')).toBeTruthy();
+ expect(getByText('No username provided')).toBeTruthy();
+ });
+
+ it('shows loading spinner when loading', () => {
+ mockUseRoute.mockReturnValue({
+ params: { username: 'testuser' },
+ } as ReturnType);
+
+ mockUseProfile.mockReturnValue({
+ data: undefined,
+ error: null,
+ isLoading: true,
+ refetch: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('profile-loading-spinner')).toBeTruthy();
+ });
+
+ it("shows account doesn't exist on error", () => {
+ mockUseRoute.mockReturnValue({
+ params: { username: 'nonexistent' },
+ } as ReturnType);
+
+ mockUseProfile.mockReturnValue({
+ data: undefined,
+ error: new Error('Not found'),
+ isLoading: false,
+ refetch: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText("This account doesn't exist")).toBeTruthy();
+ expect(getByText('Try searching for another')).toBeTruthy();
+ });
+
+ it("shows account doesn't exist on profile undefined", () => {
+ mockUseRoute.mockReturnValue({
+ params: { username: 'testuser' },
+ } as ReturnType);
+
+ mockUseProfile.mockReturnValue({
+ data: undefined,
+ error: null,
+ isLoading: false,
+ refetch: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText("This account doesn't exist")).toBeTruthy();
+ expect(getByText('Try searching for another')).toBeTruthy();
+ });
+
+ it('shows profile on profile returned', () => {
+ const mockProfile = createMockProfile();
+
+ mockUseRoute.mockReturnValue({
+ params: { username: 'testuser' },
+ } as ReturnType);
+
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ error: null,
+ isLoading: false,
+ refetch: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ // ProfileHeader should be rendered
+ expect(getByTestId('profile-header')).toBeTruthy();
+ });
+
+ it('shows blocked tab if user is blocked', () => {
+ const mockProfile = createMockProfile({
+ relationship: {
+ blocking: true,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ });
+
+ mockUseRoute.mockReturnValue({
+ params: { username: 'blockeduser' },
+ } as unknown as ReturnType);
+
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ error: null,
+ isLoading: false,
+ refetch: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByText, getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('blocked-tab')).toBeTruthy();
+ expect(getByText('View Posts')).toBeTruthy();
+ });
+
+ it('shows profile tweets when "View Posts" is clicked on blocked user', async () => {
+ const mockProfile = createMockProfile({
+ relationship: {
+ blocking: true,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
+ });
+
+ mockUseRoute.mockReturnValue({
+ params: { username: 'blockeduser' },
+ } as ReturnType);
+
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ error: null,
+ isLoading: false,
+ refetch: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByText, queryByText } = render(
+
+
+
+ );
+
+ const viewPostsButton = getByText('View Posts');
+ expect(viewPostsButton).toBeTruthy();
+
+ await act(async () => {
+ fireEvent.press(viewPostsButton);
+ });
+
+ await waitFor(() => {
+ expect(queryByText('@blockeduser is blocked')).toBeNull();
+ });
+ });
+
+ it('shows loading state when refreshing profile', async () => {
+ const mockProfile = createMockProfile();
+ const mockRefetch = jest
+ .fn()
+ .mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
+
+ mockUseRoute.mockReturnValue({
+ params: { username: 'testuser' },
+ } as ReturnType);
+
+ mockUseProfile.mockReturnValue({
+ data: mockProfile,
+ error: null,
+ isLoading: false,
+ refetch: mockRefetch,
+ } as unknown as ReturnType);
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const scrollView = getByTestId('profile-header').findByType('RCTScrollView');
+ const refreshControl = scrollView.props.refreshControl;
+
+ expect(refreshControl.props.refreshing).toBe(false);
+
+ await act(async () => {
+ refreshControl.props.onRefresh();
+ });
+
+ await waitFor(() => {
+ expect(mockRefetch).toHaveBeenCalledTimes(1);
+ });
+
+ await waitFor(() => {
+ expect(refreshControl.props.refreshing).toBe(false);
+ });
+ });
+});
diff --git a/src/__tests__/screens/profile/FollowersScreen.test.tsx b/src/__tests__/screens/profile/connections/FollowersScreen.test.tsx
similarity index 100%
rename from src/__tests__/screens/profile/FollowersScreen.test.tsx
rename to src/__tests__/screens/profile/connections/FollowersScreen.test.tsx
diff --git a/src/__tests__/screens/profile/FollowingScreen.test.tsx b/src/__tests__/screens/profile/connections/FollowingScreen.test.tsx
similarity index 100%
rename from src/__tests__/screens/profile/FollowingScreen.test.tsx
rename to src/__tests__/screens/profile/connections/FollowingScreen.test.tsx
diff --git a/src/__tests__/screens/profile/profileSetup/BioSetupScreen.test.tsx b/src/__tests__/screens/profile/profileSetup/BioSetupScreen.test.tsx
index 6b6339553..96fe724ab 100644
--- a/src/__tests__/screens/profile/profileSetup/BioSetupScreen.test.tsx
+++ b/src/__tests__/screens/profile/profileSetup/BioSetupScreen.test.tsx
@@ -5,11 +5,12 @@ import ProfileSetupBio from '@/screens/profile/profileSetup/BioSetupScreen';
import { getMyProfile, updateMyProfile, updateProfilePicture } from '@/services/me';
import { changeUsername, updateInterests } from '@/services/settings';
import { useUserStore } from '@/stores/userStore';
-import { BOTTOM_TABS, DRAWER, HOME, ROOT } from '@/utils/navigation/routeNames';
+import { PROFILE_SETUP, ROOT } from '@/utils/navigation/routeNames';
import type { UserProfile } from '@/types/user';
const mockReset = jest.fn();
+const mockNavigate = jest.fn();
const mockRoute: {
params: {
username: string | undefined;
@@ -20,6 +21,7 @@ const mockRoute: {
params: { username: 'testuser', profilePicture: '' },
};
+// Default parent state mock
const mockGetParent = jest.fn(() => ({
getState: jest.fn(() => ({
routes: [{ name: ROOT.DRAWER }],
@@ -30,6 +32,7 @@ jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
reset: mockReset,
getParent: mockGetParent,
+ navigate: mockNavigate,
}),
useRoute: () => mockRoute,
}));
@@ -96,18 +99,7 @@ const createProfile = (overrides: Partial = {}): UserProfile => ({
});
const expectNavReset = () => {
- expect(mockReset).toHaveBeenCalledWith({
- index: 0,
- routes: [
- {
- name: 'Drawer',
- params: {
- screen: DRAWER.BOTTOM_TABS,
- params: { screen: BOTTOM_TABS.HOME, params: { screen: HOME.FOR_YOU } },
- },
- },
- ],
- });
+ expect(mockNavigate).toHaveBeenCalledWith(PROFILE_SETUP.FOLLOW_SUGGESTIONS);
};
describe('ProfileSetupBio', () => {
@@ -603,4 +595,110 @@ describe('ProfileSetupBio', () => {
expectNavReset();
});
});
+
+ // --- New Tests for Increased Coverage ---
+
+ it('shows error when updateProfilePicture fails', async () => {
+ mockRoute.params = { username: 'testuser', profilePicture: 'file://img.png' };
+ (changeUsername as jest.Mock).mockResolvedValue({ success: true });
+ (updateProfilePicture as jest.Mock).mockResolvedValue({
+ success: false,
+ message: 'Failed to upload image',
+ });
+
+ const { getByTestId, getByText } = render(
+
+
+
+ );
+
+ fireEvent.changeText(getByTestId('bio-input'), 'Bio');
+ fireEvent.press(getByTestId('next-button'));
+
+ await waitFor(() => {
+ expect(updateProfilePicture).toHaveBeenCalledWith('file://img.png');
+ expect(getByText('Something went wrong')).toBeTruthy();
+ expect(getByText('Failed to upload image')).toBeTruthy();
+ expect(updateMyProfile).not.toHaveBeenCalled();
+ });
+ });
+
+ it('shows error when updateMyProfile fails (updating bio)', async () => {
+ (changeUsername as jest.Mock).mockResolvedValue({ success: true });
+ (updateMyProfile as jest.Mock).mockResolvedValue({
+ success: false,
+ message: 'Failed to update bio',
+ });
+
+ const { getByTestId, getByText } = render(
+
+
+
+ );
+
+ fireEvent.changeText(getByTestId('bio-input'), 'Bio');
+ fireEvent.press(getByTestId('next-button'));
+
+ await waitFor(() => {
+ expect(updateMyProfile).toHaveBeenCalledWith({ bio: 'Bio' });
+ expect(getByText('Something went wrong')).toBeTruthy();
+ expect(getByText('Failed to update bio')).toBeTruthy();
+ });
+ });
+
+ it('falls back to fetching full profile if update returns null data', async () => {
+ (changeUsername as jest.Mock).mockResolvedValue({ success: true });
+ // Simulate update success but no data returned
+ (updateMyProfile as jest.Mock).mockResolvedValue({ success: true, data: null });
+ // Fallback fetch
+ (getMyProfile as jest.Mock).mockResolvedValue({
+ data: createProfile({ bio: 'Fetched Bio', avatarUrl: 'http://fetched.com/img.jpg' }),
+ });
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ fireEvent.changeText(getByTestId('bio-input'), 'Bio');
+ fireEvent.press(getByTestId('next-button'));
+
+ await waitFor(() => {
+ expect(getMyProfile).toHaveBeenCalled();
+ expect(mockUpdateUser).toHaveBeenCalledWith(
+ expect.objectContaining({
+ bio: 'Fetched Bio',
+ avatarUrl: 'http://fetched.com/img.jpg',
+ })
+ );
+ });
+ });
+
+ it('navigates to Home when error screen action is pressed', async () => {
+ (changeUsername as jest.Mock).mockResolvedValue({ success: false, message: 'Fail' });
+
+ const { getByTestId, getByText } = render(
+
+
+
+ );
+
+ fireEvent.changeText(getByTestId('bio-input'), 'bio');
+ fireEvent.press(getByTestId('next-button'));
+
+ await waitFor(() => expect(getByText('Something went wrong')).toBeTruthy());
+
+ fireEvent.press(getByText('Go To Home Screen'));
+ expect(mockReset).toHaveBeenCalledWith(
+ expect.objectContaining({
+ index: 0,
+ routes: [
+ expect.objectContaining({
+ name: ROOT.DRAWER,
+ }),
+ ],
+ })
+ );
+ });
});
diff --git a/src/__tests__/screens/profile/profileSetup/FollowSuggestionsScreen.test.tsx b/src/__tests__/screens/profile/profileSetup/FollowSuggestionsScreen.test.tsx
new file mode 100644
index 000000000..bf593989a
--- /dev/null
+++ b/src/__tests__/screens/profile/profileSetup/FollowSuggestionsScreen.test.tsx
@@ -0,0 +1,173 @@
+import { fireEvent, render, waitFor, within } from '@testing-library/react-native';
+
+import FollowSuggestions from '@/screens/profile/profileSetup/FollowSuggestionsScreen';
+import { fetchFollowSuggestions } from '@/services/onBoarding';
+
+const mockReset = jest.fn();
+const mockFollowMutate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ reset: mockReset }),
+}));
+
+jest.mock('@/hooks/useTheme', () => ({
+ useTheme: () => ({ theme: 'light' }),
+}));
+
+jest.mock('@/utils/toast', () => ({
+ showToast: jest.fn(),
+}));
+
+jest.mock('@/hooks/profile/useFollowMutation', () => ({
+ useFollowMutation: () => ({
+ mutate: mockFollowMutate,
+ isPending: false,
+ }),
+}));
+
+jest.mock('@/hooks/profile/useBlockMutation', () => ({
+ useBlockMutation: () => ({
+ mutate: jest.fn(),
+ isPending: false,
+ }),
+}));
+
+jest.mock('@/services/onBoarding');
+
+const suggestions = [
+ {
+ username: 'alice',
+ displayName: 'Alice A',
+ avatarUrl: '',
+ bio: 'Hello from Alice',
+ },
+ {
+ username: 'bob',
+ displayName: 'Bob B',
+ avatarUrl: '',
+ bio: '',
+ },
+];
+
+describe('FollowSuggestionsScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders suggestions and allows following then unfollowing', async () => {
+ (fetchFollowSuggestions as jest.Mock).mockResolvedValueOnce({
+ success: true,
+ data: { suggestions },
+ });
+
+ mockFollowMutate.mockImplementation(
+ (_vars: { username: string; follow: boolean }, opts: { onSuccess?: () => void }) => {
+ if (opts?.onSuccess) opts.onSuccess();
+ }
+ );
+
+ const { getByTestId, getByText } = render( );
+
+ await waitFor(() => expect(getByText('Alice A')).toBeTruthy());
+
+ const aliceBtn = getByTestId('follow-button-alice');
+ expect(aliceBtn).toBeTruthy();
+
+ fireEvent.press(aliceBtn);
+
+ await waitFor(() => {
+ expect(mockFollowMutate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ username: 'alice',
+ follow: true,
+ }),
+ expect.any(Object)
+ );
+ expect(within(getByTestId('follow-button-alice')).getByText('Following')).toBeTruthy();
+ });
+
+ fireEvent.press(getByTestId('follow-button-alice'));
+
+ await waitFor(() => {
+ expect(mockFollowMutate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ username: 'alice',
+ follow: false,
+ }),
+ expect.any(Object)
+ );
+ expect(within(getByTestId('follow-button-alice')).getByText('Follow')).toBeTruthy();
+ });
+ });
+
+ it('enables Next when at least one user is followed and navigates home', async () => {
+ (fetchFollowSuggestions as jest.Mock).mockResolvedValueOnce({
+ success: true,
+ data: { suggestions },
+ });
+
+ mockFollowMutate.mockImplementation(
+ (_vars: { username: string; follow: boolean }, opts: { onSuccess?: () => void }) => {
+ if (opts?.onSuccess) opts.onSuccess();
+ }
+ );
+
+ const { getByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('follow-button-alice')).toBeTruthy());
+
+ const nextButton = getByTestId('next-button');
+ expect(nextButton.props.accessibilityState.disabled).toBe(true);
+
+ fireEvent.press(getByTestId('follow-button-alice'));
+
+ await waitFor(() =>
+ expect(within(getByTestId('follow-button-alice')).getByText('Following')).toBeTruthy()
+ );
+
+ await waitFor(() => {
+ expect(nextButton.props.accessibilityState.disabled).toBe(false);
+ });
+
+ fireEvent.press(nextButton);
+
+ await waitFor(() => {
+ expect(mockReset).toHaveBeenCalled();
+ });
+ });
+
+ it('shows retry when suggestions loading fails', async () => {
+ (fetchFollowSuggestions as jest.Mock).mockResolvedValueOnce({
+ success: false,
+ message: 'failed to load',
+ });
+
+ const { getByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('suggestions-error')).toBeTruthy());
+
+ (fetchFollowSuggestions as jest.Mock).mockResolvedValueOnce({
+ success: true,
+ data: { suggestions },
+ });
+
+ fireEvent.press(getByTestId('retry-suggestions'));
+
+ await waitFor(() => {
+ expect(fetchFollowSuggestions).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('shows empty state when no suggestions', async () => {
+ (fetchFollowSuggestions as jest.Mock).mockResolvedValueOnce({
+ success: true,
+ data: { suggestions: [] },
+ });
+
+ const { getByText } = render( );
+
+ await waitFor(() => {
+ expect(getByText('No suggestions available at the moment.')).toBeTruthy();
+ });
+ });
+});
diff --git a/src/__tests__/screens/profile/ProfileLikesScreen.test.tsx b/src/__tests__/screens/profile/profileTabs/ProfileLikesScreen.test.tsx
similarity index 100%
rename from src/__tests__/screens/profile/ProfileLikesScreen.test.tsx
rename to src/__tests__/screens/profile/profileTabs/ProfileLikesScreen.test.tsx
diff --git a/src/__tests__/screens/profile/ProfileMediaScreen.test.tsx b/src/__tests__/screens/profile/profileTabs/ProfileMediaScreen.test.tsx
similarity index 100%
rename from src/__tests__/screens/profile/ProfileMediaScreen.test.tsx
rename to src/__tests__/screens/profile/profileTabs/ProfileMediaScreen.test.tsx
diff --git a/src/__tests__/screens/profile/ProfileMutualsScreen.test.tsx b/src/__tests__/screens/profile/profileTabs/ProfileMutualsScreen.test.tsx
similarity index 100%
rename from src/__tests__/screens/profile/ProfileMutualsScreen.test.tsx
rename to src/__tests__/screens/profile/profileTabs/ProfileMutualsScreen.test.tsx
diff --git a/src/__tests__/screens/profile/ProfilePostsScreen.test.tsx b/src/__tests__/screens/profile/profileTabs/ProfilePostsScreen.test.tsx
similarity index 100%
rename from src/__tests__/screens/profile/ProfilePostsScreen.test.tsx
rename to src/__tests__/screens/profile/profileTabs/ProfilePostsScreen.test.tsx
diff --git a/src/__tests__/screens/profile/ProfileRepliesScreen.test.tsx b/src/__tests__/screens/profile/profileTabs/ProfileRepliesScreen.test.tsx
similarity index 100%
rename from src/__tests__/screens/profile/ProfileRepliesScreen.test.tsx
rename to src/__tests__/screens/profile/profileTabs/ProfileRepliesScreen.test.tsx
diff --git a/src/__tests__/screens/search/SearchScreen.test.tsx b/src/__tests__/screens/search/SearchScreen.test.tsx
index a5b03c2d9..dff0fd045 100644
--- a/src/__tests__/screens/search/SearchScreen.test.tsx
+++ b/src/__tests__/screens/search/SearchScreen.test.tsx
@@ -12,10 +12,22 @@ import type { SearchResultTab } from '@/types/search';
type BeforeRemoveHandler = (event: { preventDefault: () => void }) => void;
-jest.mock('@react-navigation/native', () => ({
- useNavigation: jest.fn(),
- useRoute: jest.fn(),
-}));
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return {
+ ...actual,
+ createNavigationContainerRef: () => ({
+ isReady: jest.fn(() => false),
+ getCurrentRoute: jest.fn(() => null),
+ navigate: jest.fn(),
+ dispatch: jest.fn(),
+ goBack: jest.fn(),
+ current: null,
+ }),
+ useNavigation: jest.fn(),
+ useRoute: jest.fn(),
+ };
+});
jest.mock('@/hooks/useTheme', () => ({
useTheme: () => ({ theme: 'light' }),
@@ -56,6 +68,7 @@ jest.mock('@/components/search/SearchSuggestions', () => ({
describe('SearchScreen', () => {
const mockNavigate = jest.fn();
+ const mockPush = jest.fn();
const mockGoBack = jest.fn();
const mockAddListener = jest.fn((_eventName: string, _handler: BeforeRemoveHandler) => jest.fn());
let keyboardSpy: jest.SpyInstance;
@@ -78,6 +91,7 @@ describe('SearchScreen', () => {
jest.clearAllMocks();
(useNavigation as jest.Mock).mockReturnValue({
navigate: mockNavigate,
+ push: mockPush,
goBack: mockGoBack,
addListener: mockAddListener,
});
@@ -136,7 +150,7 @@ describe('SearchScreen', () => {
const suggestionProps = getLatestSuggestionProps();
act(() => suggestionProps.onGoToProfile('neo'));
- expect(mockNavigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ expect(mockPush).toHaveBeenCalledWith(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username: 'neo' },
});
diff --git a/src/__tests__/screens/settings/AccountSettingsScreen.test.tsx b/src/__tests__/screens/settings/AccountSettingsScreen.test.tsx
new file mode 100644
index 000000000..28dc89839
--- /dev/null
+++ b/src/__tests__/screens/settings/AccountSettingsScreen.test.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+
+import * as RN from 'react-native';
+
+import { fireEvent, render } from '@testing-library/react-native';
+
+import { ThemeProvider } from '@/hooks/useTheme';
+import AccountSettingsScreen from '@/screens/settings/AccountSettingsScreen';
+import { ACCOUNT_INFO, ACCOUNT_SETTINGS } from '@/utils/navigation/routeNames';
+
+// Mock navigation
+const mockNavigate = jest.fn();
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: mockNavigate }),
+}));
+
+// Mock Card component
+jest.mock('@/components/ui/Card', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { Text, TouchableOpacity } = require('react-native');
+ return function MockCard({
+ title,
+ description,
+ onPress,
+ }: {
+ title: string;
+ description: string;
+ onPress: () => void;
+ }) {
+ return (
+
+ {title}
+ {description}
+
+ );
+ };
+});
+
+describe('AccountSettingsScreen', () => {
+ beforeEach(() => {
+ mockNavigate.mockClear();
+ jest.spyOn(RN, 'useColorScheme').mockReturnValue('light');
+ });
+
+ const renderWithTheme = (component: React.ReactElement) => {
+ return render({component} );
+ };
+
+ it('renders correctly', () => {
+ const { getByText } = renderWithTheme( );
+
+ expect(getByText('Account information')).toBeTruthy();
+ expect(getByText('View and edit your public profile, username, and email.')).toBeTruthy();
+
+ expect(getByText('Change password')).toBeTruthy();
+ expect(getByText('Update your password and manage sign-in methods.')).toBeTruthy();
+ });
+
+ it('navigates to Account Information screen on press', () => {
+ const { getByTestId } = renderWithTheme( );
+
+ fireEvent.press(getByTestId('card-Account information'));
+
+ expect(mockNavigate).toHaveBeenCalledWith(ACCOUNT_SETTINGS.ACCOUNT_INFO, {
+ screen: ACCOUNT_INFO.OVERVIEW,
+ });
+ });
+
+ it('navigates to Change Password screen on press', () => {
+ const { getByTestId } = renderWithTheme( );
+
+ fireEvent.press(getByTestId('card-Change password'));
+
+ expect(mockNavigate).toHaveBeenCalledWith(ACCOUNT_SETTINGS.CHANGE_PASSWORD);
+ });
+
+ it('applies dark theme styles', () => {
+ jest.spyOn(RN, 'useColorScheme').mockReturnValue('dark');
+ const { toJSON } = renderWithTheme( );
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/src/__tests__/screens/settings/SettingsScreen.test.tsx b/src/__tests__/screens/settings/SettingsScreen.test.tsx
index d1be77c02..8e30c3b93 100644
--- a/src/__tests__/screens/settings/SettingsScreen.test.tsx
+++ b/src/__tests__/screens/settings/SettingsScreen.test.tsx
@@ -7,6 +7,7 @@ const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
push: mockNavigate,
+ navigate: mockNavigate,
}),
}));
@@ -24,7 +25,6 @@ describe('SettingsScreen', () => {
expect(getByText('Your Account')).toBeTruthy();
expect(getByText('Privacy')).toBeTruthy();
- expect(getByText('Notifications')).toBeTruthy();
expect(toJSON()).toMatchSnapshot();
});
@@ -42,17 +42,24 @@ describe('SettingsScreen', () => {
expect(mockNavigate).toHaveBeenCalledWith('Privacy', { screen: 'Main' });
});
- it('navigates to Notifications screen when Notifications card is pressed', () => {
+ it('navigates to Appearance screen when Appearance card is pressed', () => {
const { getByText } = renderWithTheme( );
- fireEvent.press(getByText('Notifications'));
- expect(mockNavigate).toHaveBeenCalledWith('Notifications');
+ fireEvent.press(getByText('Appearance'));
+ expect(mockNavigate).toHaveBeenCalledWith('Appearance');
});
- it('navigates to Appearance screen when Appearance card is pressed', () => {
+ it('navigates to Terms of Service screen when ToS card is pressed', () => {
const { getByText } = renderWithTheme( );
- fireEvent.press(getByText('Appearance'));
- expect(mockNavigate).toHaveBeenCalledWith('Appearance');
+ fireEvent.press(getByText('Terms of Service'));
+ expect(mockNavigate).toHaveBeenCalledWith('TermsOfService');
+ });
+
+ it('navigates to Privacy Policy screen when Privacy Policy card is pressed', () => {
+ const { getByText } = renderWithTheme( );
+
+ fireEvent.press(getByText('Privacy Policy'));
+ expect(mockNavigate).toHaveBeenCalledWith('PrivacyPolicy');
});
});
diff --git a/src/__tests__/screens/settings/privacy/BlockedAccountsScreen.test.tsx b/src/__tests__/screens/settings/privacy/BlockedAccountsScreen.test.tsx
index 32e1e3eb2..c80afb641 100644
--- a/src/__tests__/screens/settings/privacy/BlockedAccountsScreen.test.tsx
+++ b/src/__tests__/screens/settings/privacy/BlockedAccountsScreen.test.tsx
@@ -9,12 +9,19 @@ import { useBlockMutation } from '@/hooks/profile/useBlockMutation';
import { useFollowMutation } from '@/hooks/profile/useFollowMutation';
import { useUserBlocks } from '@/hooks/profile/useUserBlocks';
import { useTheme } from '@/hooks/useTheme';
+import { navigationRef } from '@/navigation/navigationRef';
import BlockedAccountsScreen from '@/screens/settings/privacy/BlockedAccountsScreen';
+import { PROFILE, ROOT } from '@/utils/navigation/routeNames';
jest.mock('@/hooks/profile/useUserBlocks', () => ({ useUserBlocks: jest.fn() }));
jest.mock('@/hooks/profile/useBlockMutation', () => ({ useBlockMutation: jest.fn() }));
jest.mock('@/hooks/profile/useFollowMutation', () => ({ useFollowMutation: jest.fn() }));
jest.mock('@/hooks/useTheme', () => ({ useTheme: jest.fn() }));
+jest.mock('@/navigation/navigationRef', () => ({
+ navigationRef: {
+ navigate: jest.fn(),
+ },
+}));
const _initialOS = Platform.OS as 'ios' | 'android';
const setPlatformOS = (os: typeof _initialOS) => {
@@ -219,4 +226,124 @@ describe('BlockedAccountsScreen', () => {
alertSpy.mockRestore();
});
+
+ it('calls fetchNextPage when scrolling to end and hasNextPage is true', () => {
+ const fetchNextPage = jest.fn();
+
+ (useUserBlocks as jest.Mock).mockReturnValue({
+ data: {
+ pages: [
+ {
+ data: [
+ { username: 'user1', relationship: { blocking: true, following: false } },
+ { username: 'user2', relationship: { blocking: true, following: false } },
+ ],
+ },
+ ],
+ },
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: true,
+ fetchNextPage,
+ error: null,
+ refetch: jest.fn(),
+ isRefetching: false,
+ });
+
+ const { UNSAFE_root } = renderWithClient( );
+
+ const flatList = UNSAFE_root.findByType('RCTScrollView' as never);
+ flatList.props.onEndReached();
+
+ expect(fetchNextPage).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call fetchNextPage when hasNextPage is false', () => {
+ const fetchNextPage = jest.fn();
+
+ (useUserBlocks as jest.Mock).mockReturnValue({
+ data: {
+ pages: [
+ { data: [{ username: 'user1', relationship: { blocking: true, following: false } }] },
+ ],
+ },
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ fetchNextPage,
+ error: null,
+ refetch: jest.fn(),
+ isRefetching: false,
+ });
+
+ const { UNSAFE_root } = renderWithClient( );
+
+ const flatList = UNSAFE_root.findByType('RCTScrollView' as never);
+ flatList.props.onEndReached();
+
+ expect(fetchNextPage).not.toHaveBeenCalled();
+ });
+
+ it('does not call fetchNextPage when already fetching next page', () => {
+ const fetchNextPage = jest.fn();
+
+ (useUserBlocks as jest.Mock).mockReturnValue({
+ data: {
+ pages: [
+ { data: [{ username: 'user1', relationship: { blocking: true, following: false } }] },
+ ],
+ },
+ isLoading: false,
+ isFetchingNextPage: true,
+ hasNextPage: true,
+ fetchNextPage,
+ error: null,
+ refetch: jest.fn(),
+ isRefetching: false,
+ });
+
+ const { UNSAFE_root } = renderWithClient( );
+
+ const flatList = UNSAFE_root.findByType('RCTScrollView' as never);
+ flatList.props.onEndReached();
+
+ expect(fetchNextPage).not.toHaveBeenCalled();
+ });
+
+ it('navigates to user profile when profile is pressed', () => {
+ (useUserBlocks as jest.Mock).mockReturnValue({
+ data: {
+ pages: [
+ {
+ data: [
+ {
+ username: 'testuser',
+ displayName: 'Test User',
+ avatarUrl: null,
+ bio: 'Test bio',
+ relationship: { blocking: true, following: false },
+ },
+ ],
+ },
+ ],
+ },
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ fetchNextPage: jest.fn(),
+ error: null,
+ refetch: jest.fn(),
+ isRefetching: false,
+ });
+
+ const { getByTestId } = renderWithClient( );
+
+ const profileItem = getByTestId('avatar-image');
+ fireEvent.press(profileItem);
+
+ expect(navigationRef.navigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: 'testuser' },
+ });
+ });
});
diff --git a/src/__tests__/screens/settings/privacy/MutedAccountsScreen.test.tsx b/src/__tests__/screens/settings/privacy/MutedAccountsScreen.test.tsx
index a7be3f4fe..ca8cf1146 100644
--- a/src/__tests__/screens/settings/privacy/MutedAccountsScreen.test.tsx
+++ b/src/__tests__/screens/settings/privacy/MutedAccountsScreen.test.tsx
@@ -9,12 +9,19 @@ import { useBlockMutation } from '@/hooks/profile/useBlockMutation';
import { useFollowMutation } from '@/hooks/profile/useFollowMutation';
import { useUserMutes } from '@/hooks/profile/useUserMutes';
import { useTheme } from '@/hooks/useTheme';
+import { navigationRef } from '@/navigation/navigationRef';
import MutedAccountsScreen from '@/screens/settings/privacy/MutedAccountsScreen';
+import { PROFILE, ROOT } from '@/utils/navigation/routeNames';
jest.mock('@/hooks/profile/useUserMutes', () => ({ useUserMutes: jest.fn() }));
jest.mock('@/hooks/profile/useBlockMutation', () => ({ useBlockMutation: jest.fn() }));
jest.mock('@/hooks/profile/useFollowMutation', () => ({ useFollowMutation: jest.fn() }));
jest.mock('@/hooks/useTheme', () => ({ useTheme: jest.fn() }));
+jest.mock('@/navigation/navigationRef', () => ({
+ navigationRef: {
+ navigate: jest.fn(),
+ },
+}));
const _initialOS = Platform.OS as 'ios' | 'android';
const setPlatformOS = (os: typeof _initialOS) => {
@@ -287,4 +294,144 @@ describe('MutedAccountsScreen', () => {
alertSpy.mockRestore();
});
+
+ it('calls fetchNextPage when scrolling to end and hasNextPage is true', () => {
+ const fetchNextPage = jest.fn();
+
+ (useUserMutes as jest.Mock).mockReturnValue({
+ data: {
+ pages: [
+ {
+ data: [
+ {
+ username: 'user1',
+ relationship: { blocking: false, following: false, muted: true },
+ },
+ {
+ username: 'user2',
+ relationship: { blocking: false, following: false, muted: true },
+ },
+ ],
+ },
+ ],
+ },
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: true,
+ fetchNextPage,
+ error: null,
+ refetch: jest.fn(),
+ isRefetching: false,
+ });
+
+ const { UNSAFE_root } = renderWithClient( );
+
+ const flatList = UNSAFE_root.findByType('RCTScrollView' as never);
+ flatList.props.onEndReached();
+
+ expect(fetchNextPage).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call fetchNextPage when hasNextPage is false', () => {
+ const fetchNextPage = jest.fn();
+
+ (useUserMutes as jest.Mock).mockReturnValue({
+ data: {
+ pages: [
+ {
+ data: [
+ {
+ username: 'user1',
+ relationship: { blocking: false, following: false, muted: true },
+ },
+ ],
+ },
+ ],
+ },
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ fetchNextPage,
+ error: null,
+ refetch: jest.fn(),
+ isRefetching: false,
+ });
+
+ const { UNSAFE_root } = renderWithClient( );
+
+ const flatList = UNSAFE_root.findByType('RCTScrollView' as never);
+ flatList.props.onEndReached();
+
+ expect(fetchNextPage).not.toHaveBeenCalled();
+ });
+
+ it('does not call fetchNextPage when already fetching next page', () => {
+ const fetchNextPage = jest.fn();
+
+ (useUserMutes as jest.Mock).mockReturnValue({
+ data: {
+ pages: [
+ {
+ data: [
+ {
+ username: 'user1',
+ relationship: { blocking: false, following: false, muted: true },
+ },
+ ],
+ },
+ ],
+ },
+ isLoading: false,
+ isFetchingNextPage: true,
+ hasNextPage: true,
+ fetchNextPage,
+ error: null,
+ refetch: jest.fn(),
+ isRefetching: false,
+ });
+
+ const { UNSAFE_root } = renderWithClient( );
+
+ const flatList = UNSAFE_root.findByType('RCTScrollView' as never);
+ flatList.props.onEndReached();
+
+ expect(fetchNextPage).not.toHaveBeenCalled();
+ });
+
+ it('navigates to user profile when profile is pressed', () => {
+ (useUserMutes as jest.Mock).mockReturnValue({
+ data: {
+ pages: [
+ {
+ data: [
+ {
+ username: 'testuser',
+ displayName: 'Test User',
+ avatarUrl: null,
+ bio: 'Test bio',
+ relationship: { blocking: false, following: false, muted: true },
+ },
+ ],
+ },
+ ],
+ },
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ fetchNextPage: jest.fn(),
+ error: null,
+ refetch: jest.fn(),
+ isRefetching: false,
+ });
+
+ const { getByText } = renderWithClient( );
+
+ const usernameElement = getByText('@testuser');
+ fireEvent.press(usernameElement);
+
+ expect(navigationRef.navigate).toHaveBeenCalledWith(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: 'testuser' },
+ });
+ });
});
diff --git a/src/__tests__/screens/tweets/LikersScreen.test.tsx b/src/__tests__/screens/tweets/LikersScreen.test.tsx
index ed6d856c5..1814e620a 100644
--- a/src/__tests__/screens/tweets/LikersScreen.test.tsx
+++ b/src/__tests__/screens/tweets/LikersScreen.test.tsx
@@ -56,22 +56,37 @@ const mockLikers = [
username: 'liker1',
displayName: 'Liker One',
avatarUrl: 'https://example.com/avatar1.jpg',
- isFollowing: true,
- isFollower: false,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: true,
+ follower: false,
+ },
},
{
username: 'liker2',
displayName: 'Liker Two',
avatarUrl: null,
- isFollowing: false,
- isFollower: true,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: true,
+ },
},
{
username: 'currentuser',
displayName: 'Current User',
avatarUrl: null,
- isFollowing: null,
- isFollower: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: false,
+ follower: false,
+ },
},
];
diff --git a/src/__tests__/screens/tweets/QuotesScreen.test.tsx b/src/__tests__/screens/tweets/QuotesScreen.test.tsx
index 5a88dd9de..81b9929d5 100644
--- a/src/__tests__/screens/tweets/QuotesScreen.test.tsx
+++ b/src/__tests__/screens/tweets/QuotesScreen.test.tsx
@@ -10,15 +10,29 @@ jest.mock('@tanstack/react-query');
const mockUseInfiniteQuery = jest.fn();
const mockRootNavigate = jest.fn();
+const mockRootPush = jest.fn();
const mockTweetStackPush = jest.fn();
const mockGetParent = jest.fn(() => ({ push: mockTweetStackPush }));
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({
- navigate: mockRootNavigate,
- getParent: mockGetParent,
- }),
-}));
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return {
+ ...actual,
+ createNavigationContainerRef: () => ({
+ isReady: jest.fn(() => false),
+ getCurrentRoute: jest.fn(() => null),
+ navigate: jest.fn(),
+ dispatch: jest.fn(),
+ goBack: jest.fn(),
+ current: null,
+ }),
+ useNavigation: () => ({
+ navigate: mockRootNavigate,
+ push: mockRootPush,
+ getParent: mockGetParent,
+ }),
+ };
+});
jest.mock('@/components/ui/Spinner', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -315,7 +329,7 @@ describe('QuotesScreen', () => {
expect(getByTestId('author-press')).toBeTruthy();
fireEvent.press(getByTestId('author-press'));
- expect(mockRootNavigate).toHaveBeenCalledWith('Profile', {
+ expect(mockRootPush).toHaveBeenCalledWith('Profile', {
screen: 'UserProfile',
params: { username: 'testuser' },
});
@@ -451,7 +465,7 @@ describe('QuotesScreen', () => {
expect(getByTestId('mention-press')).toBeTruthy();
fireEvent.press(getByTestId('mention-press'));
- expect(mockRootNavigate).toHaveBeenCalledWith('Profile', {
+ expect(mockRootPush).toHaveBeenCalledWith('Profile', {
screen: 'UserProfile',
params: { username: 'mentioneduser' },
});
diff --git a/src/__tests__/screens/tweets/TweetDetailScreen.test.tsx b/src/__tests__/screens/tweets/TweetDetailScreen.test.tsx
index fbeef1d1e..55ba37178 100644
--- a/src/__tests__/screens/tweets/TweetDetailScreen.test.tsx
+++ b/src/__tests__/screens/tweets/TweetDetailScreen.test.tsx
@@ -3,20 +3,51 @@
import React from 'react';
import { Keyboard } from 'react-native';
-import { render, waitFor } from '@testing-library/react-native';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ThemeProvider } from '@/hooks/useTheme';
import TweetDetailScreen from '@/screens/tweets/TweetDetailScreen';
+
+import { useTweetDetail } from '@/hooks/tweets/useTweetDetail';
+import { useTweetReplies } from '@/hooks/tweets/useTweetReplies';
import * as tweetsService from '@/services/tweets';
+import type { DeletedTweet, Tweet, TweetWithThread } from '@/types/tweet';
+
+jest.mock('@/libs/queryClient', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { QueryClient } = require('@tanstack/react-query');
+ return {
+ queryClient: new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ },
+ }),
+ };
+});
-import type { Tweet, TweetWithThread } from '@/types/tweet';
+jest.mock('@/libs/tweetCache', () => ({
+ getTweetCache: jest.fn(() => ({
+ getTweet: jest.fn(() => null),
+ setTweet: jest.fn(),
+ setTweets: jest.fn(),
+ hasTweet: jest.fn(() => false),
+ removeTweet: jest.fn(),
+ incrementReplyCount: jest.fn(),
+ subscribe: jest.fn(() => () => {}),
+ })),
+}));
jest.mock('@/services/tweets');
jest.mock('@/stores/userStore', () => ({
useUserStore: jest.fn(() => ({
- username: 'testuser',
- displayName: 'Test User',
- avatarUrl: null,
+ user: {
+ username: 'testuser',
+ displayName: 'Test User',
+ avatarUrl: null,
+ id: 'current-user-id',
+ },
})),
}));
@@ -26,73 +57,187 @@ jest.mock('@react-navigation/elements', () => ({
const mockPush = jest.fn();
const mockNavigate = jest.fn();
+// Move initial params to a variable so we can override it in tests if needed
+let mockRouteParams = { tweetId: 'main-tweet-123' };
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({ push: mockPush, navigate: mockNavigate }),
- useRoute: () => ({ params: { tweetId: 'main-tweet-123' } }),
+ useRoute: () => ({ params: mockRouteParams }),
+}));
+
+// Mock hooks
+jest.mock('@/hooks/tweets/useTweetDetail', () => ({
+ useTweetDetail: jest.fn(),
}));
+jest.mock('@/hooks/tweets/useTweetReplies', () => ({
+ useTweetReplies: jest.fn(),
+}));
+
+const mockUseTweetDetail = useTweetDetail as jest.Mock;
+const mockUseTweetReplies = useTweetReplies as jest.Mock;
jest.mock('@shopify/flash-list', () => {
return {
FlashList: (props: any) => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ReactLocal = require('react');
- const { data, renderItem } = props;
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { View: ViewLocal } = require('react-native');
+ const { data, renderItem, ListFooterComponent, testID, refreshControl } = props;
return ReactLocal.createElement(
- ReactLocal.Fragment,
- null,
- data?.map((item: any, index: number) => renderItem({ item, index })) || null
+ ViewLocal,
+ { testID, refreshControl }, // Pass refreshControl to props so we can access it in test
+ [
+ data?.map((item: any, index: number) => renderItem({ item, index })),
+ ListFooterComponent
+ ? ReactLocal.createElement(ListFooterComponent, { key: 'footer' })
+ : null,
+ ]
);
},
};
});
-jest.mock('@/components/ui/Tweet', () => {
+jest.mock('@/components/ui', () => {
return {
Tweet: (props: any) => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ReactLocal = require('react');
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const { Text: TextLocal, TouchableOpacity: TouchableLocal } = require('react-native');
- const { tweet, onPress, detailed } = props;
- return ReactLocal.createElement(
- TouchableLocal,
- { testID: `tweet-${tweet.id}`, onPress: onPress || undefined },
- ReactLocal.createElement(
- TextLocal,
- { testID: `tweet-detailed-${tweet.id}` },
- detailed ? 'true' : 'false'
- ),
- ReactLocal.createElement(
- TextLocal,
- { testID: `tweet-onPress-${tweet.id}` },
- onPress ? 'true' : 'false'
- )
- );
+ const {
+ Text: TextLocal,
+ View: ViewLocal,
+ Pressable: PressableLocal,
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ } = require('react-native');
+ const { tweet, onPress, onPressAuthor, onPressReply, onQuote, detailed } = props;
+
+ return ReactLocal.createElement(ViewLocal, { testID: `tweet-component-${tweet.id}` }, [
+ ReactLocal.createElement(TextLocal, { key: 'content' }, tweet.content),
+ ReactLocal.createElement(PressableLocal, {
+ key: 'body',
+ testID: `tweet-press-${tweet.id}`,
+ onPress: () => onPress && onPress(tweet.id),
+ }),
+ ReactLocal.createElement(PressableLocal, {
+ key: 'author',
+ testID: `tweet-author-${tweet.id}`,
+ onPress: () => onPressAuthor && onPressAuthor(tweet.author.username),
+ }),
+ ReactLocal.createElement(PressableLocal, {
+ key: 'reply',
+ testID: `tweet-action-reply-${tweet.id}`,
+ onPress: onPressReply,
+ }),
+ ReactLocal.createElement(PressableLocal, {
+ key: 'quote',
+ testID: `tweet-action-quote-${tweet.id}`,
+ onPress: () => onQuote && onQuote(tweet),
+ }),
+ ReactLocal.createElement(PressableLocal, {
+ key: 'like-action',
+ testID: `tweet-action-like-${tweet.id}`,
+ onLongPress: () => props.onLongPressLike && props.onLongPressLike(tweet.id),
+ }),
+ ReactLocal.createElement(PressableLocal, {
+ key: 'retweet-action',
+ testID: `tweet-action-retweet-${tweet.id}`,
+ onLongPress: () => props.onLongPressRetweet && props.onLongPressRetweet(tweet.id),
+ }),
+ detailed ? ReactLocal.createElement(TextLocal, { key: 'detailed' }, 'detailed-mode') : null,
+ ]);
},
};
});
-jest.mock('@/components/ui/ReplyInput', () => 'ReplyInput');
+
+jest.mock('@/components/ui/ReplyInput', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const ReactLocal = require('react');
+ const {
+ View: ViewLocal,
+ TextInput: TextInputLocal,
+ Button: ButtonLocal,
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ } = require('react-native');
+
+ return (props: any) => {
+ return ReactLocal.createElement(ViewLocal, { testID: 'reply-input-container' }, [
+ ReactLocal.createElement(TextInputLocal, {
+ key: 'input',
+ testID: 'reply-input-field',
+ value: props.value,
+ onChangeText: props.onChangeText,
+ }),
+ ReactLocal.createElement(ButtonLocal, {
+ key: 'submit',
+ testID: 'reply-submit-button',
+ title: 'Reply',
+ onPress: props.onSubmit,
+ }),
+ ReactLocal.createElement(ButtonLocal, {
+ key: 'media',
+ testID: 'reply-media-button',
+ title: 'Media',
+ onPress: props.onOpenComposerWithMedia,
+ }),
+ ]);
+ };
+});
+
+jest.mock('@/components/ui/TweetComposer', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const ReactLocal = require('react');
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { View: ViewLocal } = require('react-native');
+ return (props: any) => {
+ if (!props.visible) return null;
+ return ReactLocal.createElement(ViewLocal, { testID: 'tweet-composer-modal' });
+ };
+});
+
jest.spyOn(Keyboard, 'dismiss');
-const mockGetTweetById = tweetsService.getTweet as jest.MockedFunction<
- typeof tweetsService.getTweet
->;
-const mockGetReplies = tweetsService.getTweetReplies as jest.MockedFunction<
- typeof tweetsService.getTweetReplies
->;
const mockPostTweet = tweetsService.postTweet as jest.MockedFunction<
typeof tweetsService.postTweet
>;
-const renderWithProviders = (component: React.ReactElement) =>
- render({component} );
+const renderWithProviders = (component: React.ReactElement) => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ return render(
+
+
+ {component}
+
+
+ );
+};
describe('TweetDetailScreen', () => {
- const mockMainTweet = {
+ // Default Data
+ const mockMainTweet: TweetWithThread = {
id: 'main-tweet-123',
- author: { username: 'author1', displayName: 'Author One', avatarUrl: null },
+ author: {
+ username: 'author1',
+ displayName: 'Author One',
+ avatarUrl: null,
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
+ },
content: 'Main tweet content',
createdAt: '2024-01-01T10:00:00Z',
replyCount: 2,
@@ -112,222 +257,384 @@ describe('TweetDetailScreen', () => {
const mockReplies: Tweet[] = [
{
- ...mockMainTweet,
id: 'reply-1',
+ author: {
+ username: 'replyUser1',
+ displayName: 'Replier One',
+ avatarUrl: null,
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
+ },
content: 'Reply 1',
+ createdAt: '2024-01-01T10:05:00Z',
+ replyCount: 0,
+ retweetCount: 0,
+ likeCount: 1,
+ isLiked: false,
+ isRetweeted: false,
+ entities: { mentions: null, hashtags: null },
+ media: [],
replyToTweetId: 'main-tweet-123',
- },
- {
- ...mockMainTweet,
- id: 'reply-2',
- content: 'Reply 2',
- replyToTweetId: 'main-tweet-123',
+ quoteToTweetId: null,
+ quotedTweet: null,
},
];
beforeEach(() => {
jest.clearAllMocks();
- mockGetTweetById.mockResolvedValue({ success: true, data: mockMainTweet });
- mockGetReplies.mockResolvedValue({
- success: true,
- data: mockReplies,
- pagination: { cursor: null, nextCursor: 'cursor-123', hasNextPage: true },
+ mockRouteParams = { tweetId: 'main-tweet-123' }; // Reset params
+
+ mockUseTweetDetail.mockReturnValue({
+ data: mockMainTweet,
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+ mockUseTweetReplies.mockReturnValue({
+ data: {
+ pages: [{ data: mockReplies }],
+ pageParams: [null],
+ },
+ fetchNextPage: jest.fn(),
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ isLoading: false,
+ refetch: jest.fn(),
});
});
- describe('Initial Load', () => {
- it('should render loading state initially', () => {
- const { toJSON } = renderWithProviders( );
- expect(toJSON()).toBeTruthy();
+ describe('Rendering', () => {
+ it('should render loading indicator when loading', () => {
+ mockUseTweetDetail.mockReturnValueOnce({
+ data: undefined,
+ isLoading: true,
+ error: null,
+ });
+ renderWithProviders( );
+ expect(screen.getByTestId('loading-indicator')).toBeTruthy();
});
- it('should fetch main tweet and replies on mount', async () => {
+ it('should render error state when main tweet fails to load', async () => {
+ mockUseTweetDetail.mockReturnValue({
+ data: null,
+ isLoading: false,
+ error: new Error('Network Error'),
+ refetch: jest.fn(),
+ });
+
renderWithProviders( );
+
await waitFor(() => {
- expect(mockGetTweetById).toHaveBeenCalledWith('main-tweet-123');
- expect(mockGetReplies).toHaveBeenCalledWith('main-tweet-123');
+ expect(screen.getByText('Network Error')).toBeTruthy();
+ expect(screen.getByTestId('retry-button')).toBeTruthy();
});
});
- it('should handle API errors gracefully', async () => {
- const error = new Error('Tweet not found');
- mockGetTweetById.mockRejectedValueOnce(error);
- const { getByText, getByTestId } = renderWithProviders( );
+ it('should render main tweet and replies correctly', async () => {
+ renderWithProviders( );
+
await waitFor(() => {
- expect(mockGetTweetById).toHaveBeenCalled();
- expect(getByText('Tweet not found')).toBeTruthy();
- expect(getByTestId('retry-button')).toBeTruthy();
+ expect(screen.getByText('Main tweet content')).toBeTruthy();
+ expect(screen.getByText('Reply 1')).toBeTruthy();
});
+
+ // Check if detailed mode is on for main tweet
+ const mainTweetComponent = screen.getByTestId('tweet-component-main-tweet-123');
+ expect(mainTweetComponent).toBeTruthy();
+ });
+
+ it('should render footer loader when fetching next page', () => {
+ mockUseTweetReplies.mockReturnValue({
+ data: { pages: [], pageParams: [] },
+ isLoading: false,
+ isFetchingNextPage: true,
+ fetchNextPage: jest.fn(),
+ refetch: jest.fn(),
+ });
+
+ renderWithProviders( );
+
+ expect(screen.queryByTestId('loading-indicator')).toBeNull(); // Main loader
});
});
- describe('Replies Management', () => {
- it('should handle tweets with no replies', async () => {
- mockGetReplies.mockResolvedValueOnce({
- success: true,
- data: [],
- pagination: { cursor: null, nextCursor: null, hasNextPage: false },
+ describe('Thread Structure', () => {
+ it('should render root and parent tweets', async () => {
+ const mockRootTweet: Tweet = { ...mockMainTweet, id: 'root-1', content: 'Root Tweet' };
+ const mockParentTweet: Tweet = { ...mockMainTweet, id: 'parent-1', content: 'Parent Tweet' };
+
+ mockUseTweetDetail.mockReturnValue({
+ data: {
+ ...mockMainTweet,
+ rootTweet: mockRootTweet,
+ parentTweets: [mockParentTweet],
+ },
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
});
+
renderWithProviders( );
- await waitFor(() => expect(mockGetReplies).toHaveBeenCalled());
+
+ await waitFor(() => {
+ expect(screen.getByText('Root Tweet')).toBeTruthy();
+ expect(screen.getByText('Parent Tweet')).toBeTruthy();
+ expect(screen.getByText('Main tweet content')).toBeTruthy();
+ });
});
- it('should handle pagination', async () => {
+ it('should render "Show more replies" when hasMoreParents is true', async () => {
+ mockUseTweetDetail.mockReturnValue({
+ data: {
+ ...mockMainTweet,
+ parentTweets: [{ ...mockMainTweet, id: 'parent-visible', content: 'Visible Parent' }],
+ hasMoreParents: true,
+ },
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+
renderWithProviders( );
- await waitFor(() => expect(mockGetReplies).toHaveBeenCalledWith('main-tweet-123'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Show more replies')).toBeTruthy();
+ });
});
- it('should handle error when loading more replies', async () => {
+ it('should render deleted parent tweet placeholder', async () => {
+ const deletedParent: DeletedTweet = { isDeleted: true };
+ mockUseTweetDetail.mockReturnValue({
+ data: {
+ ...mockMainTweet,
+ parentTweets: [deletedParent],
+ },
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+
renderWithProviders( );
- await waitFor(() => expect(mockGetReplies).toHaveBeenCalledTimes(1));
- mockGetReplies.mockRejectedValueOnce(new Error('Failed to load more'));
+
+ await waitFor(() => {
+ expect(screen.getByText('This tweet was deleted.')).toBeTruthy();
+ });
});
+ });
- describe('Refresh & Navigation', () => {
- it('should refresh data on pull to refresh', async () => {
- renderWithProviders( );
- await waitFor(() => expect(mockGetTweetById).toHaveBeenCalledTimes(1));
+ describe('Interactions', () => {
+ it('should navigate to profile on author press', async () => {
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('tweet-author-main-tweet-123')).toBeTruthy();
});
- it('should not navigate when main tweet is pressed', async () => {
- renderWithProviders( );
- await waitFor(() => expect(mockGetTweetById).toHaveBeenCalled());
- expect(mockPush).not.toHaveBeenCalled();
+ fireEvent.press(screen.getByTestId('tweet-author-main-tweet-123'));
+
+ expect(mockPush).toHaveBeenCalledWith('Profile', {
+ screen: 'UserProfile',
+ params: { username: 'author1' },
});
+ });
- it('should have navigation available', async () => {
- renderWithProviders( );
- await waitFor(() => expect(mockGetTweetById).toHaveBeenCalled());
- expect(mockNavigate).toBeDefined();
+ it('should navigate to tweet detail on reply press', async () => {
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('tweet-press-reply-1')).toBeTruthy();
});
+
+ fireEvent.press(screen.getByTestId('tweet-press-reply-1'));
+
+ expect(mockPush).toHaveBeenCalledWith('TweetDetail', { tweetId: 'reply-1' });
});
- describe('Reply Submission', () => {
- it('should handle successful reply submission', async () => {
- mockPostTweet.mockResolvedValueOnce({
- success: true,
- message: 'Tweet posted successfully',
- data: { id: 'new-reply-123' },
- });
- renderWithProviders( );
- await waitFor(() => expect(mockGetTweetById).toHaveBeenCalled());
+ it('should handle "Show more replies" press', async () => {
+ const newestParent = { ...mockMainTweet, id: 'parent-1' };
+ mockUseTweetDetail.mockReturnValue({
+ data: {
+ ...mockMainTweet,
+ parentTweets: [newestParent],
+ hasMoreParents: true,
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('thread-show-more-button')).toBeTruthy();
});
- it('should not submit empty reply', async () => {
- renderWithProviders( );
- await waitFor(() => expect(mockGetTweetById).toHaveBeenCalled());
- expect(mockPostTweet).not.toHaveBeenCalled();
+ fireEvent.press(screen.getByTestId('thread-show-more-button'));
+
+ expect(mockPush).toHaveBeenCalledWith('TweetDetail', { tweetId: 'parent-1' });
+ });
+
+ it('should handle pull to refresh', async () => {
+ const mockRefetchTweet = jest.fn().mockResolvedValue({});
+ const mockRefetchReplies = jest.fn().mockResolvedValue({});
+
+ mockUseTweetDetail.mockReturnValue({
+ data: mockMainTweet,
+ isLoading: false,
+ refetch: mockRefetchTweet,
});
- it('should handle reply submission error', async () => {
- const error = new Error('Failed to post reply');
- mockPostTweet.mockRejectedValueOnce(error);
- renderWithProviders( );
- await waitFor(() => expect(mockGetTweetById).toHaveBeenCalled());
+ mockUseTweetReplies.mockReturnValue({
+ data: { pages: [], pageParams: [] },
+ isLoading: false,
+ refetch: mockRefetchReplies,
});
- it('should dismiss keyboard after submission', async () => {
- mockPostTweet.mockResolvedValueOnce({
- success: true,
- message: 'Tweet posted successfully',
- data: { id: 'new-reply-123' },
- });
- renderWithProviders( );
- await waitFor(() => expect(mockGetTweetById).toHaveBeenCalled());
- expect(Keyboard.dismiss).toBeDefined();
+ renderWithProviders( );
+
+ const list = screen.getByTestId('flash-list');
+ const { refreshControl } = list.props;
+
+ await waitFor(async () => {
+ await refreshControl.props.onRefresh();
});
+
+ expect(mockRefetchTweet).toHaveBeenCalled();
+ expect(mockRefetchReplies).toHaveBeenCalled();
});
- describe('Edge Cases', () => {
- it('should handle missing main tweet', async () => {
- const error = new Error('Tweet not found');
- mockGetTweetById.mockRejectedValueOnce(error);
- const { getByText } = renderWithProviders( );
- await waitFor(() => {
- expect(mockGetTweetById).toHaveBeenCalled();
- expect(getByText('Tweet not found')).toBeTruthy();
- });
+ it('should navigate to activity screen on long press like', async () => {
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('tweet-action-like-main-tweet-123')).toBeTruthy();
});
- it('should handle large reply chains', async () => {
- const manyReplies = Array.from({ length: 50 }, (_, i) => ({
- ...mockMainTweet,
- id: `reply-${i}`,
- content: `Reply ${i}`,
- }));
- mockGetReplies.mockResolvedValueOnce({
- success: true,
- data: manyReplies,
- pagination: { cursor: null, nextCursor: 'next', hasNextPage: true },
- });
- renderWithProviders( );
- await waitFor(() => expect(mockGetReplies).toHaveBeenCalled());
+ fireEvent(screen.getByTestId('tweet-action-like-main-tweet-123'), 'onLongPress');
+
+ expect(mockNavigate).toHaveBeenCalledWith('Tweet', {
+ screen: 'TweetActivity',
+ params: { tweetId: 'main-tweet-123', initialTab: 'Likes' },
});
+ });
- it('should handle tweet with media', async () => {
- const tweetWithMedia: TweetWithThread = {
- ...mockMainTweet,
- media: [
- {
- url: 'https://example.com/image.jpg',
- type: 'IMAGE',
- altText: 'Test',
- width: 800,
- height: 600,
- },
- ],
- rootTweet: null,
- parentTweets: [],
- hasMoreParents: false,
- };
- mockGetTweetById.mockResolvedValueOnce({ success: true, data: tweetWithMedia });
- renderWithProviders( );
- await waitFor(() => expect(mockGetTweetById).toHaveBeenCalled());
+ it('should navigate to activity screen on long press retweet', async () => {
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('tweet-action-retweet-main-tweet-123')).toBeTruthy();
});
- it('should handle quoted tweet', async () => {
- const tweetWithQuote = {
- ...mockMainTweet,
- quoteToTweetId: 'quoted-123',
- quotedTweet: { ...mockMainTweet, id: 'quoted-123' },
- rootTweet: null,
- parentTweets: [],
- hasMoreParents: false,
- };
- mockGetTweetById.mockResolvedValueOnce({ success: true, data: tweetWithQuote });
- renderWithProviders( );
- await waitFor(() => expect(mockGetTweetById).toHaveBeenCalled());
+ fireEvent(screen.getByTestId('tweet-action-retweet-main-tweet-123'), 'onLongPress');
+
+ expect(mockNavigate).toHaveBeenCalledWith('Tweet', {
+ screen: 'TweetActivity',
+ params: { tweetId: 'main-tweet-123', initialTab: 'Reposts' },
});
});
+ });
- // Main tweet should receive onQuote prop
- // This is tested through the component rendering
- describe('Quote Tweet Functionality', () => {
- it('should pass onQuote handler to main tweet', async () => {
- renderWithProviders( );
- await waitFor(() => {
- expect(mockGetTweetById).toHaveBeenCalled();
- });
+ describe('Reply Feature', () => {
+ it('should allow typing and submitting a reply', async () => {
+ mockPostTweet.mockResolvedValueOnce({
+ success: true,
+ message: 'Replied',
+ data: { ...mockReplies[0], id: 'new-reply' },
});
- // Reply tweets should receive onQuote prop
- // This is tested through the component rendering
- it('should pass onQuote handler to reply tweets', async () => {
- renderWithProviders( );
- await waitFor(() => {
- expect(mockGetTweetById).toHaveBeenCalled();
- expect(mockGetReplies).toHaveBeenCalled();
+ renderWithProviders( );
+
+ const input = screen.getByTestId('reply-input-field');
+ fireEvent.changeText(input, 'My new reply');
+
+ const submitBtn = screen.getByTestId('reply-submit-button');
+ fireEvent.press(submitBtn);
+
+ await waitFor(() => {
+ expect(mockPostTweet).toHaveBeenCalledWith({
+ content: 'My new reply',
+ replyToTweetId: 'main-tweet-123',
});
});
- // The callback reference pattern allows tweets to update their retweet count
- // without requiring cache invalidation
- it('should handle quote with callback pattern for local updates', async () => {
- renderWithProviders( );
- await waitFor(() => {
- expect(mockGetTweetById).toHaveBeenCalled();
- });
+ await waitFor(() => {
+ expect(input.props.value).toBe('');
+ });
+ });
+
+ it('should show error if reply fails', async () => {
+ mockPostTweet.mockRejectedValueOnce(new Error('Reply Failed'));
+
+ renderWithProviders( );
+
+ const input = screen.getByTestId('reply-input-field');
+ fireEvent.changeText(input, 'Fail reply');
+ fireEvent.press(screen.getByTestId('reply-submit-button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Reply Failed')).toBeTruthy();
+ });
+ });
+
+ it('should not submit empty reply', () => {
+ renderWithProviders( );
+
+ const input = screen.getByTestId('reply-input-field');
+ fireEvent.changeText(input, ' '); // Whitespace
+
+ const submitBtn = screen.getByTestId('reply-submit-button');
+ fireEvent.press(submitBtn);
+
+ expect(mockPostTweet).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Composer & Quoting', () => {
+ it('should open composer when clicking reply action on a tweet', async () => {
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('tweet-action-reply-main-tweet-123')).toBeTruthy();
+ });
+
+ fireEvent.press(screen.getByTestId('tweet-action-reply-main-tweet-123'));
+
+ expect(screen.getByTestId('tweet-composer-modal')).toBeTruthy();
+ });
+
+ it('should open composer via route params (initialOpenComposer)', async () => {
+ mockRouteParams = { ...mockRouteParams, initialOpenComposer: true } as any;
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('tweet-composer-modal')).toBeTruthy();
+ });
+ });
+
+ it('should open quote composer when clicking quote action', async () => {
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('tweet-action-quote-main-tweet-123')).toBeTruthy();
});
+
+ fireEvent.press(screen.getByTestId('tweet-action-quote-main-tweet-123'));
+
+ expect(screen.getByTestId('tweet-composer-modal')).toBeTruthy();
+ });
+
+ it('should open composer with media picker when clicking media button in reply input', () => {
+ renderWithProviders( );
+
+ const mediaBtn = screen.getByTestId('reply-media-button');
+ fireEvent.press(mediaBtn);
+
+ expect(screen.getByTestId('tweet-composer-modal')).toBeTruthy();
});
});
});
diff --git a/src/__tests__/services/connections.test.ts b/src/__tests__/services/connections.test.ts
index 50c5e0328..f3e27be1f 100644
--- a/src/__tests__/services/connections.test.ts
+++ b/src/__tests__/services/connections.test.ts
@@ -1,8 +1,29 @@
-import api from '@/libs/api';
-import { getUserFollowers, getUserFollowing, getUserProfile } from '@/services/connections';
+import api, { ApiException } from '@/libs/api';
+import {
+ followUser,
+ getUserFollowers,
+ getUserFollowing,
+ getUserProfile,
+ unfollowUser,
+} from '@/services/connections';
jest.mock('@/libs/api', () => ({
- get: jest.fn(),
+ __esModule: true,
+ default: {
+ get: jest.fn(),
+ post: jest.fn(),
+ delete: jest.fn(),
+ },
+ ApiException: class MockApiException extends Error {
+ status: number;
+ get statusCode() {
+ return this.status;
+ }
+ constructor(status: number, _body: unknown = null, message?: string) {
+ super(message || `Request failed with ${status}`);
+ this.status = status;
+ }
+ },
}));
const mockApi = api as jest.Mocked;
@@ -31,6 +52,20 @@ describe('connections service', () => {
expect(mockApi.get).toHaveBeenCalledWith('/users/test%40user/profile');
});
+
+ it('throws error when response is not successful', async () => {
+ const mockResponse = { success: false, message: 'User not found' };
+ mockApi.get.mockResolvedValue(mockResponse);
+
+ await expect(getUserProfile('nonexistent')).rejects.toThrow('User not found');
+ });
+
+ it('throws default error when no message provided', async () => {
+ const mockResponse = { success: false };
+ mockApi.get.mockResolvedValue(mockResponse);
+
+ await expect(getUserProfile('nonexistent')).rejects.toThrow('Failed to load profile');
+ });
});
describe('getUserFollowers', () => {
@@ -64,6 +99,20 @@ describe('connections service', () => {
expect(mockApi.get).toHaveBeenCalledWith('/users/test%40user/followers?limit=20');
});
+
+ it('throws error when response is not successful', async () => {
+ const mockResponse = { success: false, message: 'Unauthorized' };
+ mockApi.get.mockResolvedValue(mockResponse);
+
+ await expect(getUserFollowers('testuser')).rejects.toThrow('Unauthorized');
+ });
+
+ it('throws default error when no message provided', async () => {
+ const mockResponse = { success: false };
+ mockApi.get.mockResolvedValue(mockResponse);
+
+ await expect(getUserFollowers('testuser')).rejects.toThrow('Failed to load followers');
+ });
});
describe('getUserFollowing', () => {
@@ -97,5 +146,89 @@ describe('connections service', () => {
expect(mockApi.get).toHaveBeenCalledWith('/users/test%40user/following?limit=20');
});
+
+ it('throws error when response is not successful', async () => {
+ const mockResponse = { success: false, message: 'Unauthorized' };
+ mockApi.get.mockResolvedValue(mockResponse);
+
+ await expect(getUserFollowing('testuser')).rejects.toThrow('Unauthorized');
+ });
+
+ it('throws default error when no message provided', async () => {
+ const mockResponse = { success: false };
+ mockApi.get.mockResolvedValue(mockResponse);
+
+ await expect(getUserFollowing('testuser')).rejects.toThrow('Failed to load following');
+ });
+ });
+
+ describe('followUser', () => {
+ it('calls api.post with correct endpoint', async () => {
+ const mockResponse = { success: true, message: 'Followed successfully' };
+ mockApi.post.mockResolvedValue(mockResponse);
+
+ const result = await followUser('targetuser');
+
+ expect(mockApi.post).toHaveBeenCalledWith('/users/targetuser/following', {});
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('handles 409 conflict (already following)', async () => {
+ const error = new ApiException(409, null, 'Already following');
+ mockApi.post.mockRejectedValue(error);
+
+ const result = await followUser('targetuser');
+
+ expect(result).toEqual({ success: true });
+ });
+
+ it('throws other errors', async () => {
+ const error = new Error('Network error');
+ mockApi.post.mockRejectedValue(error);
+
+ await expect(followUser('targetuser')).rejects.toThrow('Network error');
+ });
+
+ it('throws non-409 ApiException errors', async () => {
+ const error = new ApiException(500, null, 'Server error');
+ mockApi.post.mockRejectedValue(error);
+
+ await expect(followUser('targetuser')).rejects.toThrow('Server error');
+ });
+ });
+
+ describe('unfollowUser', () => {
+ it('calls api.delete with correct endpoint', async () => {
+ const mockResponse = { success: true, message: 'Unfollowed successfully' };
+ mockApi.delete.mockResolvedValue(mockResponse);
+
+ const result = await unfollowUser('targetuser');
+
+ expect(mockApi.delete).toHaveBeenCalledWith('/users/targetuser/following');
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('handles 409 conflict (not following)', async () => {
+ const error = new ApiException(409, null, 'Not following');
+ mockApi.delete.mockRejectedValue(error);
+
+ const result = await unfollowUser('targetuser');
+
+ expect(result).toEqual({ success: true });
+ });
+
+ it('throws other errors', async () => {
+ const error = new Error('Network error');
+ mockApi.delete.mockRejectedValue(error);
+
+ await expect(unfollowUser('targetuser')).rejects.toThrow('Network error');
+ });
+
+ it('throws non-409 ApiException errors', async () => {
+ const error = new ApiException(401, null, 'Unauthorized');
+ mockApi.delete.mockRejectedValue(error);
+
+ await expect(unfollowUser('targetuser')).rejects.toThrow('Unauthorized');
+ });
});
});
diff --git a/src/__tests__/services/devices.test.ts b/src/__tests__/services/devices.test.ts
new file mode 100644
index 000000000..44f064e49
--- /dev/null
+++ b/src/__tests__/services/devices.test.ts
@@ -0,0 +1,64 @@
+import api from '@/libs/api';
+import { registerDevice, togglePushNotifications } from '@/services/devices';
+
+jest.mock('@/libs/api');
+
+describe('devices service', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('registerDevice', () => {
+ it('should call api.post with fcmToken', async () => {
+ const mockResponse = { success: true, message: 'Device registered' };
+ (api.post as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await registerDevice('test-fcm-token-123');
+
+ expect(api.post).toHaveBeenCalledWith('/devices', { fcmToken: 'test-fcm-token-123' });
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle api error', async () => {
+ const mockError = new Error('Network error');
+ (api.post as jest.Mock).mockRejectedValue(mockError);
+
+ await expect(registerDevice('test-fcm-token')).rejects.toThrow('Network error');
+ });
+ });
+
+ describe('togglePushNotifications', () => {
+ it('should call api.put with fcmToken and enable=true', async () => {
+ const mockResponse = { success: true, message: 'Push notifications enabled' };
+ (api.put as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await togglePushNotifications('test-fcm-token-123', true);
+
+ expect(api.put).toHaveBeenCalledWith('/devices/push', {
+ fcmToken: 'test-fcm-token-123',
+ enable: true,
+ });
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should call api.put with fcmToken and enable=false', async () => {
+ const mockResponse = { success: true, message: 'Push notifications disabled' };
+ (api.put as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await togglePushNotifications('test-fcm-token-123', false);
+
+ expect(api.put).toHaveBeenCalledWith('/devices/push', {
+ fcmToken: 'test-fcm-token-123',
+ enable: false,
+ });
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle api error', async () => {
+ const mockError = new Error('Server error');
+ (api.put as jest.Mock).mockRejectedValue(mockError);
+
+ await expect(togglePushNotifications('test-fcm-token', true)).rejects.toThrow('Server error');
+ });
+ });
+});
diff --git a/src/__tests__/services/explore.test.ts b/src/__tests__/services/explore.test.ts
index a5ba038e6..a4d59220f 100644
--- a/src/__tests__/services/explore.test.ts
+++ b/src/__tests__/services/explore.test.ts
@@ -34,7 +34,18 @@ describe('explore service', () => {
{
id: 't1',
content: 'hello',
- author: { username: 'u1', displayName: 'User 1', avatarUrl: null },
+ author: {
+ username: 'u1',
+ displayName: 'User 1',
+ avatarUrl: null,
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
+ },
createdAt: '2023-01-01',
replyCount: 0,
retweetCount: 0,
@@ -66,7 +77,7 @@ describe('explore service', () => {
it('getTrending hits /explore/trending', async () => {
const mockResponse: TrendsResponse = {
success: true,
- data: [{ hashtag: 'react', tweetCount: 100, category: 'tech' }],
+ data: [{ hashtag: 'react', tweetsCount: 100, category: 'tech' }],
};
mockApi.get.mockResolvedValue(mockResponse);
@@ -81,7 +92,7 @@ describe('explore service', () => {
it('getNewsTrends hits /explore/news', async () => {
const mockResponse: TrendsResponse = {
success: true,
- data: [{ hashtag: 'breaking', tweetCount: 50, category: 'news' }],
+ data: [{ hashtag: 'breaking', tweetsCount: 50, category: 'news' }],
};
mockApi.get.mockResolvedValue(mockResponse);
@@ -94,7 +105,7 @@ describe('explore service', () => {
it('getSportsTrends hits /explore/sports', async () => {
const mockResponse: TrendsResponse = {
success: true,
- data: [{ hashtag: 'football', tweetCount: 75, category: 'sports' }],
+ data: [{ hashtag: 'football', tweetsCount: 75, category: 'sports' }],
};
mockApi.get.mockResolvedValue(mockResponse);
@@ -107,7 +118,7 @@ describe('explore service', () => {
it('getEntertainmentTrends hits /explore/entertainment', async () => {
const mockResponse: TrendsResponse = {
success: true,
- data: [{ hashtag: 'movie', tweetCount: 30, category: 'entertainment' }],
+ data: [{ hashtag: 'movie', tweetsCount: 30, category: 'entertainment' }],
};
mockApi.get.mockResolvedValue(mockResponse);
diff --git a/src/__tests__/services/me.test.ts b/src/__tests__/services/me.test.ts
index 0e570475f..5585f87cf 100644
--- a/src/__tests__/services/me.test.ts
+++ b/src/__tests__/services/me.test.ts
@@ -1,227 +1,319 @@
import api from '@/libs/api';
import {
+ blockUser,
deleteBanner,
getMyProfile,
+ muteUser,
+ unblockUser,
+ unmuteUser,
updateBanner,
updateMyProfile,
updateProfilePicture,
} from '@/services/me';
-jest.mock('@/libs/api', () => ({
- __esModule: true,
- default: {
- get: jest.fn(),
- patch: jest.fn(),
- post: jest.fn(),
- delete: jest.fn(),
- },
-}));
+jest.mock('@/libs/api', () => {
+ class MockApiException extends Error {
+ statusCode: number;
+ constructor(message: string, statusCode: number) {
+ super(message);
+ this.name = 'ApiException';
+ this.statusCode = statusCode;
+ }
+ }
+
+ return {
+ __esModule: true,
+ default: {
+ get: jest.fn(),
+ patch: jest.fn(),
+ post: jest.fn(),
+ delete: jest.fn(),
+ },
+ ApiException: MockApiException,
+ };
+});
describe('me service', () => {
beforeEach(() => jest.clearAllMocks());
- it('getMyProfile calls api.get and returns data', async () => {
- const user = {
- username: 'alice',
- displayName: 'Alice',
- bio: null,
- bioEntities: null,
- avatarUrl: null,
- bannerUrl: null,
- location: null,
- websiteUrl: null,
- birthDate: null,
- joinedAt: '2020-01-01',
- relationship: {
- blocking: false,
- blockedBy: false,
- muted: false,
- following: null,
- follower: null,
- },
- followingCount: 1,
- followersCount: 2,
- mutualsCount: null,
- mutualUsers: null,
- };
-
- (api.get as jest.Mock).mockResolvedValue({ success: true, data: user });
-
- const res = await getMyProfile();
-
- expect(api.get).toHaveBeenCalledWith('/me');
- expect(res.data).toEqual(user);
- });
+ describe('profile fetch/update', () => {
+ // … existing tests unchanged …
+ it('getMyProfile calls api.get and returns data', async () => {
+ const user = {
+ username: 'alice',
+ displayName: 'Alice',
+ bio: null,
+ bioEntities: null,
+ avatarUrl: null,
+ bannerUrl: null,
+ location: null,
+ websiteUrl: null,
+ birthDate: null,
+ joinedAt: '2020-01-01',
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ following: null,
+ follower: null,
+ },
+ followingCount: 1,
+ followersCount: 2,
+ mutualsCount: null,
+ mutualUsers: null,
+ };
- it('propagates errors from api.get', async () => {
- (api.get as jest.Mock).mockRejectedValue(new Error('network'));
+ (api.get as jest.Mock).mockResolvedValue({ success: true, data: user });
- await expect(getMyProfile()).rejects.toThrow('network');
- expect(api.get).toHaveBeenCalledWith('/me');
- });
+ const res = await getMyProfile();
- it('updateMyProfile handles null avatar and banner correctly', async () => {
- const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
+ expect(api.get).toHaveBeenCalledWith('/me');
+ expect(res.data).toEqual(user);
+ });
- const profileData = {
- avatar: null,
- banner: null,
- };
+ it('propagates errors from api.get', async () => {
+ (api.get as jest.Mock).mockRejectedValue(new Error('network'));
- (api.patch as jest.Mock).mockResolvedValue({ success: true });
+ await expect(getMyProfile()).rejects.toThrow('network');
+ expect(api.get).toHaveBeenCalledWith('/me');
+ });
- await updateMyProfile(profileData);
+ it('updateMyProfile handles null avatar and banner correctly', async () => {
+ const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
- expect(formDataAppendSpy).toHaveBeenCalledWith(
- 'data',
- JSON.stringify({ deleteAvatar: true, deleteBanner: true })
- );
+ const profileData = {
+ avatar: null,
+ banner: null,
+ };
- const formData = (api.patch as jest.Mock).mock.calls[0][1] as FormData;
+ (api.patch as jest.Mock).mockResolvedValue({ success: true });
- formDataAppendSpy.mockRestore();
- expect(api.patch).toHaveBeenCalledWith('/me', formData);
- });
+ await updateMyProfile(profileData);
- it('updateMyProfile handles avatarUrl upload correctly', async () => {
- const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
+ expect(formDataAppendSpy).toHaveBeenCalledWith(
+ 'data',
+ JSON.stringify({ deleteAvatar: true, deleteBanner: true })
+ );
- const profileData = {
- avatar: { uri: 'file://path/to/avatar.jpg', name: 'avatar.jpg', type: 'image/jpeg' },
- };
+ const formData = (api.patch as jest.Mock).mock.calls[0][1] as FormData;
- (api.patch as jest.Mock).mockResolvedValue({ success: true });
+ formDataAppendSpy.mockRestore();
+ expect(api.patch).toHaveBeenCalledWith('/me', formData);
+ });
- await updateMyProfile(profileData);
+ it('updateMyProfile handles avatarUrl upload correctly', async () => {
+ const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
- expect(formDataAppendSpy).toHaveBeenCalled();
+ const profileData = {
+ avatar: { uri: 'file://path/to/avatar.jpg', name: 'avatar.jpg', type: 'image/jpeg' },
+ };
- expect(formDataAppendSpy).toHaveBeenCalledWith(
- 'avatar',
- expect.objectContaining({
- uri: 'file://path/to/avatar.jpg',
- name: 'avatar.jpg',
- type: 'image/jpeg',
- })
- );
+ (api.patch as jest.Mock).mockResolvedValue({ success: true });
- const formData = (api.patch as jest.Mock).mock.calls[0][1] as FormData;
+ await updateMyProfile(profileData);
- formDataAppendSpy.mockRestore();
- expect(api.patch).toHaveBeenCalledWith('/me', formData);
- });
+ expect(formDataAppendSpy).toHaveBeenCalled();
- it('updateMyProfile handles bannerUrl upload correctly', async () => {
- const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
+ expect(formDataAppendSpy).toHaveBeenCalledWith(
+ 'avatar',
+ expect.objectContaining({
+ uri: 'file://path/to/avatar.jpg',
+ name: 'avatar.jpg',
+ type: 'image/jpeg',
+ })
+ );
- const profileData = {
- banner: { uri: 'file://path/to/banner.png', name: 'banner.png', type: 'image/png' },
- };
+ const formData = (api.patch as jest.Mock).mock.calls[0][1] as FormData;
- (api.patch as jest.Mock).mockResolvedValue({ success: true });
+ formDataAppendSpy.mockRestore();
+ expect(api.patch).toHaveBeenCalledWith('/me', formData);
+ });
- await updateMyProfile(profileData);
+ it('updateMyProfile handles bannerUrl upload correctly', async () => {
+ const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
- expect(formDataAppendSpy).toHaveBeenCalled();
+ const profileData = {
+ banner: { uri: 'file://path/to/banner.png', name: 'banner.png', type: 'image/png' },
+ };
- expect(formDataAppendSpy).toHaveBeenCalledWith(
- 'banner',
- expect.objectContaining({
- uri: 'file://path/to/banner.png',
- name: 'banner.png',
- type: 'image/png',
- })
- );
+ (api.patch as jest.Mock).mockResolvedValue({ success: true });
+
+ await updateMyProfile(profileData);
- const formData = (api.patch as jest.Mock).mock.calls[0][1] as FormData;
+ expect(formDataAppendSpy).toHaveBeenCalled();
- formDataAppendSpy.mockRestore();
- expect(api.patch).toHaveBeenCalledWith('/me', formData);
- });
+ expect(formDataAppendSpy).toHaveBeenCalledWith(
+ 'banner',
+ expect.objectContaining({
+ uri: 'file://path/to/banner.png',
+ name: 'banner.png',
+ type: 'image/png',
+ })
+ );
- it('updateMyProfile handles other profile data correctly', async () => {
- const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
+ const formData = (api.patch as jest.Mock).mock.calls[0][1] as FormData;
- const profileData = {
- displayName: 'New Name',
- bio: 'This is my bio',
- location: 'Earth',
- websiteUrl: 'https://example.com',
- };
+ formDataAppendSpy.mockRestore();
+ expect(api.patch).toHaveBeenCalledWith('/me', formData);
+ });
- (api.patch as jest.Mock).mockResolvedValue({ success: true });
+ it('updateMyProfile handles other profile data correctly', async () => {
+ const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
- await updateMyProfile(profileData);
-
- expect(formDataAppendSpy).toHaveBeenCalledWith(
- 'data',
- JSON.stringify({
+ const profileData = {
displayName: 'New Name',
bio: 'This is my bio',
location: 'Earth',
websiteUrl: 'https://example.com',
- })
- );
-
- const formData = (api.patch as jest.Mock).mock.calls[0][1] as FormData;
-
- formDataAppendSpy.mockRestore();
- expect(api.patch).toHaveBeenCalledWith('/me', formData);
- });
-
- it('updateMyProfile propagates errors from api.patch', async () => {
- (api.patch as jest.Mock).mockRejectedValue(new Error('network'));
-
- await expect(updateMyProfile({ displayName: 'New Name' })).rejects.toThrow('network');
- expect(api.patch).toHaveBeenCalled();
+ };
+
+ (api.patch as jest.Mock).mockResolvedValue({ success: true });
+
+ await updateMyProfile(profileData);
+
+ expect(formDataAppendSpy).toHaveBeenCalledWith(
+ 'data',
+ JSON.stringify({
+ displayName: 'New Name',
+ bio: 'This is my bio',
+ location: 'Earth',
+ websiteUrl: 'https://example.com',
+ })
+ );
+
+ const formData = (api.patch as jest.Mock).mock.calls[0][1] as FormData;
+
+ formDataAppendSpy.mockRestore();
+ expect(api.patch).toHaveBeenCalledWith('/me', formData);
+ });
+
+ it('updateMyProfile propagates errors from api.patch', async () => {
+ (api.patch as jest.Mock).mockRejectedValue(new Error('network'));
+
+ await expect(updateMyProfile({ displayName: 'New Name' })).rejects.toThrow('network');
+ expect(api.patch).toHaveBeenCalled();
+ });
+
+ it('updateProfilePicture handles avatar upload correctly', async () => {
+ const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
+
+ (api.post as jest.Mock).mockResolvedValue({ success: true });
+
+ const image = { uri: 'file://path/to/new-avatar.jpg' };
+ await updateProfilePicture(image);
+
+ expect(formDataAppendSpy).toHaveBeenCalledWith(
+ 'profilePicture',
+ expect.objectContaining({
+ uri: image.uri,
+ name: 'new-avatar.jpg',
+ type: 'image/jpeg',
+ })
+ );
+ const formData = (api.post as jest.Mock).mock.calls[0][1] as FormData;
+
+ formDataAppendSpy.mockRestore();
+ expect(api.post).toHaveBeenCalledWith('/me/profile-picture', formData);
+ });
+
+ it('updateBanner handles banner upload correctly', async () => {
+ const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
+
+ (api.post as jest.Mock).mockResolvedValue({ success: true });
+
+ const imageUri = 'file://path/to/new-banner.jpg';
+ await updateBanner(imageUri);
+
+ expect(formDataAppendSpy).toHaveBeenCalledWith(
+ 'banner',
+ expect.objectContaining({
+ uri: imageUri,
+ name: 'new-banner.jpg',
+ type: 'image/jpeg',
+ })
+ );
+ const formData = (api.post as jest.Mock).mock.calls[0][1] as FormData;
+
+ formDataAppendSpy.mockRestore();
+ expect(api.post).toHaveBeenCalledWith('/me/banner', formData);
+ });
+
+ it('deleteBanner handles deleting banner correctly', async () => {
+ await deleteBanner();
+ expect(api.delete).toHaveBeenCalledWith('/me/banner');
+ });
});
- it('updateProfilePicture handles avatar upload correctly', async () => {
- const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
-
- (api.post as jest.Mock).mockResolvedValue({ success: true });
-
- const image = { uri: 'file://path/to/new-avatar.jpg' };
- await updateProfilePicture(image);
-
- expect(formDataAppendSpy).toHaveBeenCalledWith(
- 'profilePicture',
- expect.objectContaining({
- uri: image.uri,
- name: 'new-avatar.jpg',
- type: 'image/jpeg',
- })
- );
- const formData = (api.post as jest.Mock).mock.calls[0][1] as FormData;
-
- formDataAppendSpy.mockRestore();
- expect(api.post).toHaveBeenCalledWith('/me/profile-picture', formData);
- });
-
- it('updateBanner handles banner upload correctly', async () => {
- const formDataAppendSpy = jest.spyOn(FormData.prototype, 'append');
-
- (api.post as jest.Mock).mockResolvedValue({ success: true });
-
- const imageUri = 'file://path/to/new-banner.jpg';
- await updateBanner(imageUri);
-
- expect(formDataAppendSpy).toHaveBeenCalledWith(
- 'banner',
- expect.objectContaining({
- uri: imageUri,
- name: 'new-banner.jpg',
- type: 'image/jpeg',
- })
- );
- const formData = (api.post as jest.Mock).mock.calls[0][1] as FormData;
-
- formDataAppendSpy.mockRestore();
- expect(api.post).toHaveBeenCalledWith('/me/banner', formData);
+ describe('block/unblock', () => {
+ it('blockUser posts to the correct endpoint and returns response on success', async () => {
+ (api.post as jest.Mock).mockResolvedValue({ success: true });
+ const res = await blockUser('alice');
+ expect(api.post).toHaveBeenCalledWith('/me/blocks/alice', {});
+ expect(res).toEqual({ success: true });
+ });
+
+ it('blockUser propagates errors on failure', async () => {
+ (api.post as jest.Mock).mockRejectedValue(new Error('Network error'));
+ await expect(blockUser('alice')).rejects.toThrow('Network error');
+ expect(api.post).toHaveBeenCalledWith('/me/blocks/alice', {});
+ });
+
+ it('unblockUser propagates errors on failure', async () => {
+ (api.delete as jest.Mock).mockRejectedValue(new Error('Server error'));
+ await expect(unblockUser('alice')).rejects.toThrow('Server error');
+ expect(api.delete).toHaveBeenCalledWith('/me/blocks/alice');
+ });
+
+ it('blockUser propagates errors on failure', async () => {
+ (api.post as jest.Mock).mockRejectedValue(new Error('Network error'));
+ await expect(blockUser('alice')).rejects.toThrow('Network error');
+ expect(api.post).toHaveBeenCalledWith('/me/blocks/alice', {});
+ });
+
+ it('unblockUser propagates errors on failure', async () => {
+ (api.delete as jest.Mock).mockRejectedValue(new Error('Server error'));
+ await expect(unblockUser('alice')).rejects.toThrow('Server error');
+ expect(api.delete).toHaveBeenCalledWith('/me/blocks/alice');
+ });
});
- it('deleteBanner handles deleting banner correctly', async () => {
- await deleteBanner();
- expect(api.delete).toHaveBeenCalledWith('/me/banner');
+ describe('mute/unmute', () => {
+ it('muteUser posts to the correct endpoint and returns response on success', async () => {
+ (api.post as jest.Mock).mockResolvedValue({ success: true });
+ const res = await muteUser('alice');
+ expect(api.post).toHaveBeenCalledWith('/me/mutes/alice', {});
+ expect(res).toEqual({ success: true });
+ });
+
+ it('muteUser propagates errors on failure', async () => {
+ (api.post as jest.Mock).mockRejectedValue(new Error('Too Many Requests'));
+ await expect(muteUser('alice')).rejects.toThrow('Too Many Requests');
+ expect(api.post).toHaveBeenCalledWith('/me/mutes/alice', {});
+ });
+
+ it('unmuteUser propagates errors on failure', async () => {
+ (api.delete as jest.Mock).mockRejectedValue(new Error('Unauthorized'));
+ await expect(unmuteUser('alice')).rejects.toThrow('Unauthorized');
+ expect(api.delete).toHaveBeenCalledWith('/me/mutes/alice');
+ });
+
+ it('unmuteUser deletes the correct endpoint and returns response on success', async () => {
+ (api.delete as jest.Mock).mockResolvedValue({ success: true });
+ const res = await unmuteUser('alice');
+ expect(api.delete).toHaveBeenCalledWith('/me/mutes/alice');
+ expect(res).toEqual({ success: true });
+ });
+
+ it('muteUser propagates errors on failure', async () => {
+ (api.post as jest.Mock).mockRejectedValue(new Error('Too Many Requests'));
+ await expect(muteUser('alice')).rejects.toThrow('Too Many Requests');
+ expect(api.post).toHaveBeenCalledWith('/me/mutes/alice', {});
+ });
+
+ it('unmuteUser propagates errors on failure', async () => {
+ (api.delete as jest.Mock).mockRejectedValue(new Error('Unauthorized'));
+ await expect(unmuteUser('alice')).rejects.toThrow('Unauthorized');
+ expect(api.delete).toHaveBeenCalledWith('/me/mutes/alice');
+ });
});
});
diff --git a/src/__tests__/services/oauth.test.tsx b/src/__tests__/services/oauth.test.tsx
index 670522250..3413ef01e 100644
--- a/src/__tests__/services/oauth.test.tsx
+++ b/src/__tests__/services/oauth.test.tsx
@@ -5,7 +5,7 @@ import OAuthComplete from '@/screens/auth/OAuthComplete';
import { getMyProfile } from '@/services/me';
import { oauthComplete } from '@/services/oauth';
import { useSessionStore } from '@/stores/sessionStore';
-import { BOTTOM_TABS, DRAWER, HOME, ROOT } from '@/utils/navigation/routeNames';
+import { ROOT } from '@/utils/navigation/routeNames';
const mockReset = jest.fn();
jest.mock('@react-navigation/native', () => ({
@@ -96,15 +96,7 @@ describe('OAuth Complete screen', () => {
});
expect(mockReset).toHaveBeenCalledWith({
index: 0,
- routes: [
- {
- name: ROOT.DRAWER,
- params: {
- screen: DRAWER.BOTTOM_TABS,
- params: { screen: BOTTOM_TABS.HOME, params: { screen: HOME.FOR_YOU } },
- },
- },
- ],
+ routes: [{ name: ROOT.PROFILE_SETUP }],
});
});
});
diff --git a/src/__tests__/services/onBoarding.test.ts b/src/__tests__/services/onBoarding.test.ts
index 39931ae77..e1d34fcf3 100644
--- a/src/__tests__/services/onBoarding.test.ts
+++ b/src/__tests__/services/onBoarding.test.ts
@@ -1,5 +1,10 @@
import api from '@/libs/api';
-import { fetchUsernameSuggestions, OnBoardingStatusResponse } from '@/services/onBoarding';
+import {
+ fetchFollowSuggestions,
+ fetchUsernameSuggestions,
+ OnBoardingFollowResponse,
+ OnBoardingUsernameResponse,
+} from '@/services/onBoarding';
jest.mock('@/libs/api');
@@ -12,7 +17,7 @@ describe('onBoarding service', () => {
describe('fetchUsernameSuggestions', () => {
it('should return suggestions when API call is successful', async () => {
- const mockResponse: OnBoardingStatusResponse = {
+ const mockResponse: OnBoardingUsernameResponse = {
success: true,
data: {
suggestions: ['user1', 'user2', 'user3'],
@@ -55,7 +60,7 @@ describe('onBoarding service', () => {
});
it('should return empty array when API returns no suggestions', async () => {
- const mockResponse: OnBoardingStatusResponse = {
+ const mockResponse: OnBoardingUsernameResponse = {
success: true,
data: {
suggestions: [],
@@ -81,7 +86,7 @@ describe('onBoarding service', () => {
});
it('should return suggestions with message when API includes a message', async () => {
- const mockResponse: OnBoardingStatusResponse = {
+ const mockResponse: OnBoardingUsernameResponse = {
success: true,
data: {
suggestions: ['suggested_user1', 'suggested_user2'],
@@ -99,7 +104,7 @@ describe('onBoarding service', () => {
});
it('should handle typed parameter in URL with encoding', async () => {
- const mockResponse: OnBoardingStatusResponse = {
+ const mockResponse: OnBoardingUsernameResponse = {
success: true,
data: {
suggestions: ['takenuser1', 'takenuser2'],
@@ -121,4 +126,114 @@ describe('onBoarding service', () => {
expect(mockApi.get).toHaveBeenCalledWith('/onboarding/username-suggestions');
});
});
+
+ describe('fetchFollowSuggestions', () => {
+ it('should return suggestions when API call is successful', async () => {
+ const mockResponse: OnBoardingFollowResponse = {
+ success: true,
+ data: {
+ suggestions: [
+ {
+ username: 'user1',
+ displayName: 'User One',
+ avatarUrl: 'https://example.com/1.jpg',
+ bio: 'Bio 1',
+ },
+ {
+ username: 'user2',
+ displayName: 'User Two',
+ avatarUrl: 'https://example.com/2.jpg',
+ bio: 'Bio 2',
+ },
+ { username: 'user3', displayName: 'User Three' },
+ ],
+ },
+ };
+
+ mockApi.get.mockResolvedValue(mockResponse);
+
+ const result = await fetchFollowSuggestions();
+
+ expect(mockApi.get).toHaveBeenCalledWith('/onboarding/follow-suggestions');
+ expect(mockApi.get).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(mockResponse);
+ expect(result.success).toBe(true);
+ expect(result.data.suggestions).toHaveLength(3);
+ });
+
+ it('should return empty suggestions with success false when API call fails', async () => {
+ mockApi.get.mockRejectedValue(new Error('Network error'));
+
+ const result = await fetchFollowSuggestions();
+
+ expect(mockApi.get).toHaveBeenCalledWith('/onboarding/follow-suggestions');
+ expect(mockApi.get).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({
+ success: false,
+ data: { suggestions: [] },
+ message: 'Failed to fetch follow suggestions.',
+ });
+ });
+
+ it('should handle API errors gracefully', async () => {
+ mockApi.get.mockRejectedValue(new Error('Server error'));
+
+ const result = await fetchFollowSuggestions();
+
+ expect(result.success).toBe(false);
+ expect(result.data.suggestions).toEqual([]);
+ expect(result.message).toBe('Failed to fetch follow suggestions.');
+ });
+
+ it('should return empty array when API returns no suggestions', async () => {
+ const mockResponse: OnBoardingFollowResponse = {
+ success: true,
+ data: {
+ suggestions: [],
+ },
+ };
+
+ mockApi.get.mockResolvedValue(mockResponse);
+
+ const result = await fetchFollowSuggestions();
+
+ expect(result.success).toBe(true);
+ expect(result.data.suggestions).toEqual([]);
+ });
+
+ it('should handle API timeout errors', async () => {
+ mockApi.get.mockRejectedValue(new Error('Timeout'));
+
+ const result = await fetchFollowSuggestions();
+
+ expect(result.success).toBe(false);
+ expect(result.data.suggestions).toEqual([]);
+ expect(result.message).toBe('Failed to fetch follow suggestions.');
+ });
+
+ it('should return suggestions with message when API includes a message', async () => {
+ const mockResponse: OnBoardingFollowResponse = {
+ success: true,
+ data: {
+ suggestions: [
+ { username: 'suggested_user1', displayName: 'Suggested User 1', bio: 'Bio for user 1' },
+ {
+ username: 'suggested_user2',
+ displayName: 'Suggested User 2',
+ avatarUrl: 'https://example.com/avatar.jpg',
+ },
+ ],
+ },
+ message: 'Follow suggestions generated successfully',
+ };
+
+ mockApi.get.mockResolvedValue(mockResponse);
+
+ const result = await fetchFollowSuggestions();
+
+ expect(result.success).toBe(true);
+ expect(result.data.suggestions).toHaveLength(2);
+ expect(result.message).toBe('Follow suggestions generated successfully');
+ });
+ });
});
diff --git a/src/__tests__/services/search.test.ts b/src/__tests__/services/search.test.ts
index 1683ff84d..57de6a303 100644
--- a/src/__tests__/services/search.test.ts
+++ b/src/__tests__/services/search.test.ts
@@ -1,6 +1,6 @@
import api from '@/libs/api';
import {
- searchTopHashtags,
+ searchSuggestions,
searchTweets,
searchUsers,
searchUsersSuggestions,
@@ -37,10 +37,10 @@ describe('search service', () => {
expect(api.get).toHaveBeenCalledWith('/search/users/suggestions?query=foo+bar%2Fbaz');
});
- it('requests top hashtags with encoded query', async () => {
- await searchTopHashtags('#hello');
+ it('requests search suggestions with encoded query', async () => {
+ await searchSuggestions('#hello');
- expect(api.get).toHaveBeenCalledWith('/search/hashtags/top?query=%23hello');
+ expect(api.get).toHaveBeenCalledWith('/search/suggestions?query=%23hello');
});
it('searches tweets with all params', async () => {
diff --git a/src/__tests__/services/settings.test.ts b/src/__tests__/services/settings.test.ts
index dac3085a7..b14827732 100644
--- a/src/__tests__/services/settings.test.ts
+++ b/src/__tests__/services/settings.test.ts
@@ -7,6 +7,8 @@ import {
getCountries,
getInterests,
getSettingsData,
+ getUserBlocks,
+ getUserMutes,
resendOtp,
updateInterests,
verifyChangeEmail,
@@ -407,4 +409,82 @@ describe('settings service', () => {
await expect(updateInterests(['gaming'])).rejects.toThrow('Network error');
});
});
+
+ describe('getUserBlocks', () => {
+ it('fetches blocked users successfully with cursor', async () => {
+ const mockResponse = {
+ success: true,
+ data: [],
+ pagination: { hasNextPage: false },
+ };
+ mockedApi.get.mockResolvedValueOnce(mockResponse);
+
+ const result = await getUserBlocks('cursor-123', 10);
+
+ expect(mockedApi.get).toHaveBeenCalledWith(
+ expect.stringContaining('me/settings/blocks?cursor=cursor-123&limit=10')
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('fetches blocked users successfully without cursor (uses default limit)', async () => {
+ const mockResponse = {
+ success: true,
+ data: [],
+ };
+ mockedApi.get.mockResolvedValueOnce(mockResponse);
+
+ const result = await getUserBlocks();
+
+ expect(mockedApi.get).toHaveBeenCalledWith(expect.stringContaining('limit=20'));
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('throws error when API returns unsuccessful response', async () => {
+ mockedApi.get.mockResolvedValueOnce({
+ success: false,
+ message: 'Failed to load blocked accounts',
+ });
+
+ await expect(getUserBlocks()).rejects.toThrow('Failed to load blocked accounts');
+ });
+ });
+
+ describe('getUserMutes', () => {
+ it('fetches muted users successfully with cursor', async () => {
+ const mockResponse = {
+ success: true,
+ data: [],
+ pagination: { hasNextPage: false },
+ };
+ mockedApi.get.mockResolvedValueOnce(mockResponse);
+
+ const result = await getUserMutes('cursor-456', 50);
+
+ expect(mockedApi.get).toHaveBeenCalledWith(
+ expect.stringContaining('me/settings/mutes?cursor=cursor-456&limit=50')
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('fetches muted users successfully without cursor (uses default limit)', async () => {
+ const mockResponse = {
+ success: true,
+ data: [],
+ };
+ mockedApi.get.mockResolvedValueOnce(mockResponse);
+
+ await getUserMutes();
+ expect(mockedApi.get).toHaveBeenCalledWith(expect.stringContaining('limit=20'));
+ });
+
+ it('throws error when API returns unsuccessful response', async () => {
+ mockedApi.get.mockResolvedValueOnce({
+ success: false,
+ message: 'Error fetching mutes',
+ });
+
+ await expect(getUserMutes()).rejects.toThrow('Error fetching mutes');
+ });
+ });
});
diff --git a/src/__tests__/services/socket.test.ts b/src/__tests__/services/socket.test.ts
index 80e8231e8..0c818fdcf 100644
--- a/src/__tests__/services/socket.test.ts
+++ b/src/__tests__/services/socket.test.ts
@@ -74,22 +74,6 @@ describe('socket service', () => {
expect(ioModule.io).toHaveBeenCalledTimes(2);
});
- it('joinConversation emits join_conversation', () => {
- socketModule.initSocket('token-join');
- socketModule.joinConversation('conv-1');
- expect(
- ioModule.__emits.some((e: any) => e.event === 'join_conversation' && e.payload === 'conv-1')
- ).toBe(true);
- });
-
- it('leaveConversation emits leave_conversation', () => {
- socketModule.initSocket('token-leave');
- socketModule.leaveConversation('conv-2');
- expect(
- ioModule.__emits.some((e: any) => e.event === 'leave_conversation' && e.payload === 'conv-2')
- ).toBe(true);
- });
-
it('sendMessage emits send_message with uuid and returns same clientMessageId', () => {
socketModule.initSocket('token-send');
const returnedId = socketModule.sendMessage('conv-3', 'Hello world', null);
diff --git a/src/__tests__/services/user.test.ts b/src/__tests__/services/user.test.ts
new file mode 100644
index 000000000..251439b3e
--- /dev/null
+++ b/src/__tests__/services/user.test.ts
@@ -0,0 +1,146 @@
+import api from '@/libs/api';
+import { mockUsers } from '@/mocks/data/userMock';
+import { followUser, getUserProfile, unfollowUser } from '@/services/user';
+
+describe('User Service', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should fetch user profile', async () => {
+ const mockUsername = 'johndoe';
+ const mockProfile = mockUsers[mockUsername];
+
+ const mockResponse = {
+ data: mockProfile,
+ message: 'User profile fetched successfully.',
+ success: true,
+ };
+
+ (api.get as jest.Mock) = jest.fn().mockResolvedValueOnce(mockResponse);
+
+ const result = await getUserProfile(mockUsername);
+
+ expect(api.get).toHaveBeenCalledWith(`/users/${mockUsername}/profile`);
+ expect(result).toEqual(mockResponse);
+ });
+
+ describe('followUser', () => {
+ it('should follow a user successfully', async () => {
+ const mockUsername = 'janedoe';
+ const mockResponse = {
+ message: 'User followed successfully.',
+ success: true,
+ };
+
+ (api.post as jest.Mock) = jest.fn().mockResolvedValueOnce(mockResponse);
+
+ const result = await followUser(mockUsername);
+
+ expect(api.post).toHaveBeenCalledWith(`/users/${mockUsername}/following`, undefined);
+ expect(api.post).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(mockResponse);
+ expect(result.success).toBe(true);
+ });
+
+ it('should handle special characters in username', async () => {
+ const mockUsername = 'user.name+123';
+ const encodedUsername = encodeURIComponent(mockUsername);
+ const mockResponse = {
+ message: 'User followed successfully.',
+ success: true,
+ };
+
+ (api.post as jest.Mock) = jest.fn().mockResolvedValueOnce(mockResponse);
+
+ const result = await followUser(mockUsername);
+
+ expect(api.post).toHaveBeenCalledWith(`/users/${encodedUsername}/following`, undefined);
+ expect(result.success).toBe(true);
+ });
+
+ it('should handle API errors when following fails', async () => {
+ const mockUsername = 'johndoe';
+ const mockError = new Error('Network error');
+
+ (api.post as jest.Mock) = jest.fn().mockRejectedValueOnce(mockError);
+
+ await expect(followUser(mockUsername)).rejects.toThrow('Network error');
+ expect(api.post).toHaveBeenCalledWith(`/users/${mockUsername}/following`, undefined);
+ });
+
+ it('should handle already following scenario', async () => {
+ const mockUsername = 'existingfollowee';
+ const mockResponse = {
+ message: 'Already following this user.',
+ success: false,
+ };
+
+ (api.post as jest.Mock) = jest.fn().mockResolvedValueOnce(mockResponse);
+
+ const result = await followUser(mockUsername);
+
+ expect(result.success).toBe(false);
+ expect(result.message).toBe('Already following this user.');
+ });
+ });
+
+ describe('unfollowUser', () => {
+ it('should unfollow a user successfully', async () => {
+ const mockUsername = 'janedoe';
+ const mockResponse = {
+ message: 'User unfollowed successfully.',
+ success: true,
+ };
+
+ (api.delete as jest.Mock) = jest.fn().mockResolvedValueOnce(mockResponse);
+
+ const result = await unfollowUser(mockUsername);
+
+ expect(api.delete).toHaveBeenCalledWith(`/users/${mockUsername}/following`);
+ expect(result).toEqual(mockResponse);
+ expect(result.success).toBe(true);
+ });
+
+ it('should handle special characters in username', async () => {
+ const mockUsername = 'user.name+123';
+ const encodedUsername = encodeURIComponent(mockUsername);
+ const mockResponse = {
+ message: 'User unfollowed successfully.',
+ success: true,
+ };
+
+ (api.delete as jest.Mock) = jest.fn().mockResolvedValueOnce(mockResponse);
+
+ const result = await unfollowUser(mockUsername);
+
+ expect(api.delete).toHaveBeenCalledWith(`/users/${encodedUsername}/following`);
+ expect(result.success).toBe(true);
+ });
+
+ it('should handle API errors when unfollowing fails', async () => {
+ const mockUsername = 'johndoe';
+ const mockError = new Error('Network error');
+
+ (api.delete as jest.Mock) = jest.fn().mockRejectedValueOnce(mockError);
+
+ await expect(unfollowUser(mockUsername)).rejects.toThrow('Network error');
+ expect(api.delete).toHaveBeenCalledWith(`/users/${mockUsername}/following`);
+ });
+
+ it('should handle not-following scenario', async () => {
+ const mockUsername = 'notfollowed';
+ const mockResponse = {
+ message: 'Not following this user.',
+ success: false,
+ };
+
+ (api.delete as jest.Mock) = jest.fn().mockResolvedValueOnce(mockResponse);
+
+ const result = await unfollowUser(mockUsername);
+
+ expect(result.success).toBe(false);
+ expect(result.message).toBe('Not following this user.');
+ });
+ });
+});
diff --git a/src/__tests__/services/users.test.ts b/src/__tests__/services/users.test.ts
new file mode 100644
index 000000000..11cebd0d4
--- /dev/null
+++ b/src/__tests__/services/users.test.ts
@@ -0,0 +1,222 @@
+import api from '@/libs/api';
+import {
+ getUserLikes,
+ getUserMedia,
+ getUserMutuals,
+ getUserReplies,
+ getUserTweets,
+} from '@/services/users';
+
+jest.mock('@/libs/api');
+
+describe('users service', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('getUserTweets', () => {
+ it('should fetch user tweets with default limit', async () => {
+ const mockResponse = {
+ success: true,
+ data: [{ id: 'tweet-1', content: 'Test tweet' }],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await getUserTweets('testuser');
+
+ expect(api.get).toHaveBeenCalledWith('/users/testuser/tweets?limit=20');
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should fetch user tweets with cursor and custom limit', async () => {
+ const mockResponse = {
+ success: true,
+ data: [],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await getUserTweets('testuser', 'cursor123', 10);
+
+ expect(api.get).toHaveBeenCalledWith('/users/testuser/tweets?cursor=cursor123&limit=10');
+ });
+
+ it('should throw error when response is not successful', async () => {
+ const mockResponse = {
+ success: false,
+ message: 'User not found',
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await expect(getUserTweets('nonexistent')).rejects.toThrow('User not found');
+ });
+
+ it('should encode special characters in username', async () => {
+ const mockResponse = {
+ success: true,
+ data: [],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await getUserTweets('user@test');
+
+ expect(api.get).toHaveBeenCalledWith('/users/user%40test/tweets?limit=20');
+ });
+ });
+
+ describe('getUserReplies', () => {
+ it('should fetch user replies with default limit', async () => {
+ const mockResponse = {
+ success: true,
+ data: [{ id: 'reply-1', content: 'Test reply' }],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await getUserReplies('testuser');
+
+ expect(api.get).toHaveBeenCalledWith('/users/testuser/replies?limit=20');
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should fetch user replies with cursor and custom limit', async () => {
+ const mockResponse = {
+ success: true,
+ data: [],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await getUserReplies('testuser', 'cursor456', 15);
+
+ expect(api.get).toHaveBeenCalledWith('/users/testuser/replies?cursor=cursor456&limit=15');
+ });
+
+ it('should throw error when response is not successful', async () => {
+ const mockResponse = {
+ success: false,
+ message: 'Failed to load replies',
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await expect(getUserReplies('testuser')).rejects.toThrow('Failed to load replies');
+ });
+ });
+
+ describe('getUserMedia', () => {
+ it('should fetch user media with default limit', async () => {
+ const mockResponse = {
+ success: true,
+ data: [{ id: 'media-1', type: 'IMAGE' }],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await getUserMedia('testuser');
+
+ expect(api.get).toHaveBeenCalledWith('/users/testuser/media?limit=20');
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should fetch user media with cursor and custom limit', async () => {
+ const mockResponse = {
+ success: true,
+ data: [],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await getUserMedia('testuser', 'cursorABC', 5);
+
+ expect(api.get).toHaveBeenCalledWith('/users/testuser/media?cursor=cursorABC&limit=5');
+ });
+
+ it('should throw error when response is not successful', async () => {
+ const mockResponse = {
+ success: false,
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await expect(getUserMedia('testuser')).rejects.toThrow('Failed to load media');
+ });
+ });
+
+ describe('getUserLikes', () => {
+ it('should fetch user likes with default limit', async () => {
+ const mockResponse = {
+ success: true,
+ data: [{ id: 'like-1', content: 'Liked tweet' }],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await getUserLikes('testuser');
+
+ expect(api.get).toHaveBeenCalledWith('/users/testuser/likes?limit=20');
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should fetch user likes with cursor and custom limit', async () => {
+ const mockResponse = {
+ success: true,
+ data: [],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await getUserLikes('testuser', 'cursorXYZ', 25);
+
+ expect(api.get).toHaveBeenCalledWith('/users/testuser/likes?cursor=cursorXYZ&limit=25');
+ });
+
+ it('should throw error when response is not successful', async () => {
+ const mockResponse = {
+ success: false,
+ message: 'Unauthorized',
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await expect(getUserLikes('testuser')).rejects.toThrow('Unauthorized');
+ });
+ });
+
+ describe('getUserMutuals', () => {
+ it('should fetch user mutuals with default limit', async () => {
+ const mockResponse = {
+ success: true,
+ data: [{ username: 'mutual1', displayName: 'Mutual One' }],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await getUserMutuals('testuser');
+
+ expect(api.get).toHaveBeenCalledWith('/users/testuser/mutual?limit=20');
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should fetch user mutuals with cursor and custom limit', async () => {
+ const mockResponse = {
+ success: true,
+ data: [],
+ pagination: { nextCursor: null },
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await getUserMutuals('testuser', 'cursor999', 30);
+
+ expect(api.get).toHaveBeenCalledWith('/users/testuser/mutual?cursor=cursor999&limit=30');
+ });
+
+ it('should throw error when response is not successful', async () => {
+ const mockResponse = {
+ success: false,
+ };
+ (api.get as jest.Mock).mockResolvedValue(mockResponse);
+
+ await expect(getUserMutuals('testuser')).rejects.toThrow('Failed to load mutuals');
+ });
+ });
+});
diff --git a/src/__tests__/stores/reactionStore.test.ts b/src/__tests__/stores/reactionStore.test.ts
new file mode 100644
index 000000000..01c1e2ca2
--- /dev/null
+++ b/src/__tests__/stores/reactionStore.test.ts
@@ -0,0 +1,174 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { act, renderHook } from '@testing-library/react-native';
+import * as SecureStore from 'expo-secure-store';
+
+import { useReactionStore } from '../../stores/reactionStore';
+
+jest.mock('expo-secure-store', () => ({
+ getItemAsync: jest.fn(),
+ setItemAsync: jest.fn().mockResolvedValue(null),
+}));
+
+describe('reactionStore', () => {
+ const DEFAULT_REACTIONS = ['❤️', '😂', '😮', '😢', '🙏', '🔥', '👍'];
+
+ beforeEach(() => {
+ const { reactionCounts, quickReactions, temporaryEmoji } = useReactionStore.getState();
+ useReactionStore.setState({
+ quickReactions: DEFAULT_REACTIONS,
+ reactionCounts: new Map(DEFAULT_REACTIONS.map((emoji) => [emoji, 0])),
+ temporaryEmoji: null,
+ });
+
+ jest.clearAllMocks();
+ });
+
+ it('should initialize with default values', () => {
+ const { result } = renderHook(() => useReactionStore());
+
+ expect(result.current.quickReactions).toEqual(DEFAULT_REACTIONS);
+ expect(result.current.temporaryEmoji).toBeNull();
+ DEFAULT_REACTIONS.forEach((emoji) => {
+ expect(result.current.reactionCounts.get(emoji)).toBe(0);
+ });
+ });
+
+ describe('loadReactions', () => {
+ it('should load reactions from SecureStore correctly', async () => {
+ const mockStoredData = [
+ { emoji: '🚀', count: 10 },
+ { emoji: '❤️', count: 5 },
+ ];
+ (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(JSON.stringify(mockStoredData));
+
+ const { result } = renderHook(() => useReactionStore());
+
+ await act(async () => {
+ await result.current.loadReactions();
+ });
+
+ expect(SecureStore.getItemAsync).toHaveBeenCalledWith('reaction-counts');
+ expect(result.current.reactionCounts.get('🚀')).toBe(10);
+ expect(result.current.reactionCounts.get('❤️')).toBe(5);
+ expect(result.current.quickReactions[0]).toBe('🚀');
+ });
+
+ it('should handle empty storage/errors gracefully', async () => {
+ (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null);
+
+ const { result } = renderHook(() => useReactionStore());
+
+ act(() => {
+ useReactionStore.setState({ quickReactions: ['A'] });
+ });
+
+ await act(async () => {
+ await result.current.loadReactions();
+ });
+ });
+
+ it('should reset to defaults on storage error', async () => {
+ (SecureStore.getItemAsync as jest.Mock).mockRejectedValue(new Error('Storage error'));
+
+ const { result } = renderHook(() => useReactionStore());
+
+ act(() => {
+ useReactionStore.setState({ quickReactions: ['Dirty'] });
+ });
+
+ await act(async () => {
+ await result.current.loadReactions();
+ });
+
+ expect(result.current.quickReactions).toEqual(DEFAULT_REACTIONS);
+ });
+ });
+
+ describe('addRecentReaction', () => {
+ it('should increment reaction count and save to storage', async () => {
+ const { result } = renderHook(() => useReactionStore());
+ const emoji = '❤️';
+
+ await act(async () => {
+ result.current.addRecentReaction(emoji);
+ });
+
+ expect(result.current.reactionCounts.get(emoji)).toBe(1);
+ expect(SecureStore.setItemAsync).toHaveBeenCalled();
+
+ const callArgs = (SecureStore.setItemAsync as jest.Mock).mock.calls[0];
+ expect(callArgs[0]).toBe('reaction-counts');
+ const savedData = JSON.parse(callArgs[1]);
+ const savedEmoji = savedData.find((r: any) => r.emoji === emoji);
+ expect(savedEmoji.count).toBe(1);
+ });
+
+ it('should update quickReactions when a new emoji becomes popular', async () => {
+ const { result } = renderHook(() => useReactionStore());
+ const newEmoji = '🚀';
+
+ await act(async () => {
+ result.current.addRecentReaction(newEmoji);
+ });
+
+ expect(result.current.quickReactions[0]).toBe(newEmoji);
+ });
+
+ it('should set temporaryEmoji if emoji is NOT in top ranked yet', async () => {
+ const { result } = renderHook(() => useReactionStore());
+
+ const populatedMap = new Map();
+ const topEmojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣'];
+ topEmojis.forEach((e) => populatedMap.set(e, 10));
+
+ act(() => {
+ useReactionStore.setState({
+ reactionCounts: populatedMap,
+ quickReactions: topEmojis,
+ });
+ });
+
+ const newWeakEmoji = '👋';
+ await act(async () => {
+ result.current.addRecentReaction(newWeakEmoji);
+ });
+
+ expect(result.current.temporaryEmoji).toBe(newWeakEmoji);
+ expect(result.current.quickReactions).not.toContain(newWeakEmoji);
+ });
+
+ it('should NOT set temporaryEmoji if emoji IS in top ranked', async () => {
+ const { result } = renderHook(() => useReactionStore());
+ const topEmoji = '❤️';
+
+ await act(async () => {
+ result.current.addRecentReaction(topEmoji);
+ });
+
+ expect(result.current.temporaryEmoji).toBeNull();
+ });
+ });
+
+ describe('getDisplayReactions', () => {
+ it('should return quickReactions when no temporary emoji', () => {
+ const { result } = renderHook(() => useReactionStore());
+ const display = result.current.getDisplayReactions();
+ expect(display).toEqual(result.current.quickReactions);
+ });
+
+ it('should replace last reaction with temporary emoji if set', () => {
+ const { result } = renderHook(() => useReactionStore());
+ const temp = '👋';
+
+ act(() => {
+ useReactionStore.setState({ temporaryEmoji: temp });
+ });
+
+ const display = result.current.getDisplayReactions();
+ expect(display[display.length - 1]).toBe(temp);
+ expect(display.length).toBe(result.current.quickReactions.length);
+ expect(result.current.quickReactions).not.toContain(temp);
+ });
+ });
+});
diff --git a/src/__tests__/stores/timelineStore.test.ts b/src/__tests__/stores/timelineStore.test.ts
new file mode 100644
index 000000000..6a0d1acbc
--- /dev/null
+++ b/src/__tests__/stores/timelineStore.test.ts
@@ -0,0 +1,116 @@
+import { useTimelineStore } from '@/stores/timelineStore';
+
+describe('timelineStore', () => {
+ beforeEach(() => {
+ useTimelineStore.setState({ followingNewTweetAuthors: null });
+ });
+
+ describe('initial state', () => {
+ it('has null followingNewTweetAuthors by default', () => {
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toBeNull();
+ });
+ });
+
+ describe('setFollowingNewTweetAuthors', () => {
+ it('sets followingNewTweetAuthors to an array of authors', () => {
+ const authors = ['https://example.com/avatar1.jpg', 'https://example.com/avatar2.jpg'];
+
+ useTimelineStore.getState().setFollowingNewTweetAuthors(authors);
+
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toEqual(authors);
+ });
+
+ it('sets followingNewTweetAuthors to null', () => {
+ useTimelineStore.getState().setFollowingNewTweetAuthors(['https://example.com/avatar1.jpg']);
+ expect(useTimelineStore.getState().followingNewTweetAuthors).not.toBeNull();
+
+ useTimelineStore.getState().setFollowingNewTweetAuthors(null);
+
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toBeNull();
+ });
+
+ it('sets followingNewTweetAuthors to an empty array', () => {
+ useTimelineStore.getState().setFollowingNewTweetAuthors([]);
+
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toEqual([]);
+ });
+
+ it('handles single author', () => {
+ const singleAuthor = ['https://example.com/avatar.jpg'];
+
+ useTimelineStore.getState().setFollowingNewTweetAuthors(singleAuthor);
+
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toEqual(singleAuthor);
+ });
+
+ it('handles multiple authors', () => {
+ const multipleAuthors = [
+ 'https://example.com/avatar1.jpg',
+ 'https://example.com/avatar2.jpg',
+ 'https://example.com/avatar3.jpg',
+ 'https://example.com/avatar4.jpg',
+ ];
+
+ useTimelineStore.getState().setFollowingNewTweetAuthors(multipleAuthors);
+
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toEqual(multipleAuthors);
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toHaveLength(4);
+ });
+ });
+
+ describe('clearFollowingNewTweetAuthors', () => {
+ it('clears followingNewTweetAuthors to null', () => {
+ useTimelineStore
+ .getState()
+ .setFollowingNewTweetAuthors([
+ 'https://example.com/avatar1.jpg',
+ 'https://example.com/avatar2.jpg',
+ ]);
+ expect(useTimelineStore.getState().followingNewTweetAuthors).not.toBeNull();
+
+ useTimelineStore.getState().clearFollowingNewTweetAuthors();
+
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toBeNull();
+ });
+
+ it('is idempotent when already null', () => {
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toBeNull();
+
+ useTimelineStore.getState().clearFollowingNewTweetAuthors();
+
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toBeNull();
+ });
+
+ it('clears empty array to null', () => {
+ useTimelineStore.getState().setFollowingNewTweetAuthors([]);
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toEqual([]);
+
+ useTimelineStore.getState().clearFollowingNewTweetAuthors();
+
+ expect(useTimelineStore.getState().followingNewTweetAuthors).toBeNull();
+ });
+ });
+
+ describe('selector usage', () => {
+ it('returns correct value with selector for followingNewTweetAuthors', () => {
+ const authors = ['https://example.com/avatar.jpg'];
+ useTimelineStore.getState().setFollowingNewTweetAuthors(authors);
+
+ const selectedAuthors = useTimelineStore.getState().followingNewTweetAuthors;
+
+ expect(selectedAuthors).toEqual(authors);
+ });
+
+ it('returns correct value with selector for setFollowingNewTweetAuthors', () => {
+ const setFn = useTimelineStore.getState().setFollowingNewTweetAuthors;
+
+ expect(typeof setFn).toBe('function');
+ });
+
+ it('returns correct value with selector for clearFollowingNewTweetAuthors', () => {
+ const clearFn = useTimelineStore.getState().clearFollowingNewTweetAuthors;
+
+ expect(typeof clearFn).toBe('function');
+ });
+ });
+});
diff --git a/src/__tests__/utils/createIcon.test.ts b/src/__tests__/utils/createIcon.test.ts
new file mode 100644
index 000000000..09a3f1ef0
--- /dev/null
+++ b/src/__tests__/utils/createIcon.test.ts
@@ -0,0 +1,74 @@
+import React from 'react';
+
+import { MaterialIcons } from '@expo/vector-icons';
+
+import { createIcon } from '@/utils/createIcon';
+
+import type { IconConfig } from '@/types/icon';
+
+describe('createIcon', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('logs console error and returns null for invalid icon config', () => {
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ const element = createIcon({
+ icon: { name: 'home' } as unknown as IconConfig,
+ color: 'red-500',
+ size: 20,
+ isNativeColor: true,
+ });
+
+ expect(spy).toHaveBeenCalledWith('Not a valid Expo icon config');
+ expect(element).toBeNull();
+
+ spy.mockRestore();
+ });
+
+ it('applies native color via `color` prop when isNativeColor=true (default)', () => {
+ const config: IconConfig = {
+ component: MaterialIcons,
+ name: 'home',
+ props: { accessibilityRole: 'image' },
+ };
+
+ const element = createIcon({
+ icon: config,
+ color: 'blue',
+ size: 24,
+ });
+
+ expect(React.isValidElement(element)).toBe(true);
+
+ expect(element?.props.color).toBe('blue');
+ expect(element?.props.size).toBe(24);
+ expect(element?.props.name).toBe('home');
+ expect(element?.props.accessibilityRole).toBe('image');
+
+ expect(element?.props.className).toBeUndefined();
+ });
+
+ it('applies Tailwind class via `className` when isNativeColor=false', () => {
+ const config: IconConfig = {
+ component: MaterialIcons,
+ name: 'home',
+ props: { accessibilityRole: 'image' },
+ };
+
+ const element = createIcon({
+ icon: config,
+ color: 'red-500',
+ size: 18,
+ isNativeColor: false,
+ });
+
+ expect(React.isValidElement(element)).toBe(true);
+
+ expect(element?.props.color).toBeUndefined();
+ expect(element?.props.className).toBe('text-red-500');
+ expect(element?.props.size).toBe(18);
+ expect(element?.props.name).toBe('home');
+ });
+});
diff --git a/src/__tests__/utils/dateUtils.test.ts b/src/__tests__/utils/dateUtils.test.ts
new file mode 100644
index 000000000..1e732906d
--- /dev/null
+++ b/src/__tests__/utils/dateUtils.test.ts
@@ -0,0 +1,189 @@
+import { formatDateSeparator, getDateKey, isDifferentDay } from '@/utils/dateUtils';
+
+describe('dateUtils', () => {
+ describe('formatDateSeparator', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ // Set to noon local time to avoid timezone edge cases
+ const today = new Date();
+ today.setHours(12, 0, 0, 0);
+ jest.setSystemTime(today);
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('returns "Today" for current date', () => {
+ const today = new Date();
+ today.setHours(10, 30, 0, 0);
+ const result = formatDateSeparator(today.toISOString());
+ expect(result).toBe('Today');
+ });
+
+ it('returns "Yesterday" for previous day', () => {
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ yesterday.setHours(22, 0, 0, 0);
+ const result = formatDateSeparator(yesterday.toISOString());
+ expect(result).toBe('Yesterday');
+ });
+
+ it('returns formatted date for older dates', () => {
+ const oldDate = new Date();
+ oldDate.setDate(oldDate.getDate() - 5);
+ oldDate.setHours(15, 0, 0, 0);
+ const result = formatDateSeparator(oldDate.toISOString());
+ expect(result).toMatch(/\w+ \d+, \d{4}/);
+ });
+
+ it('returns formatted date for dates from previous year', () => {
+ const lastYear = new Date();
+ lastYear.setFullYear(lastYear.getFullYear() - 1);
+ lastYear.setMonth(11, 25);
+ const result = formatDateSeparator(lastYear.toISOString());
+ expect(result).toMatch(/\w+ \d+, \d{4}/);
+ });
+
+ it('handles date at start of day', () => {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const result = formatDateSeparator(today.toISOString());
+ expect(result).toBe('Today');
+ });
+
+ it('handles date at end of day', () => {
+ const today = new Date();
+ today.setHours(23, 59, 59, 999);
+ const result = formatDateSeparator(today.toISOString());
+ expect(result).toBe('Today');
+ });
+
+ it('correctly identifies yesterday at end of day', () => {
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ yesterday.setHours(23, 59, 59, 999);
+ const result = formatDateSeparator(yesterday.toISOString());
+ expect(result).toBe('Yesterday');
+ });
+
+ it('formats date two days ago as full date', () => {
+ const twoDaysAgo = new Date();
+ twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
+ twoDaysAgo.setHours(12, 0, 0, 0);
+ const result = formatDateSeparator(twoDaysAgo.toISOString());
+ expect(result).toMatch(/\w+ \d+, \d{4}/);
+ });
+ });
+
+ describe('getDateKey', () => {
+ it('returns date key in YYYY-MM-DD format', () => {
+ const date = new Date('2024-03-15T12:30:00');
+ const result = getDateKey(date.toISOString());
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+
+ it('pads single digit months with zero', () => {
+ const date = new Date('2024-01-05T12:00:00');
+ const result = getDateKey(date.toISOString());
+ expect(result).toMatch(/^\d{4}-0\d-0\d$/);
+ });
+
+ it('pads single digit days with zero', () => {
+ const date = new Date('2024-12-05T12:00:00');
+ const result = getDateKey(date.toISOString());
+ expect(result).toMatch(/^\d{4}-\d{2}-0\d$/);
+ });
+
+ it('handles double digit months and days', () => {
+ const date = new Date('2024-11-25T12:00:00');
+ const result = getDateKey(date.toISOString());
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+
+ it('ignores time component', () => {
+ const date = new Date('2024-03-15');
+ date.setHours(0, 0, 0, 0);
+ const result1 = getDateKey(date.toISOString());
+ date.setHours(23, 59, 59, 999);
+ const result2 = getDateKey(date.toISOString());
+ expect(result1).toBe(result2);
+ });
+
+ it('handles different timezones by using local date', () => {
+ const result = getDateKey('2024-06-20T08:30:00+05:30');
+ // Result depends on system timezone but should be consistent
+ expect(result).toMatch(/\d{4}-\d{2}-\d{2}/);
+ });
+ });
+
+ describe('isDifferentDay', () => {
+ it('returns false for same date with different times', () => {
+ const date = new Date('2024-03-15');
+ date.setHours(10, 0, 0, 0);
+ const date1 = date.toISOString();
+ date.setHours(22, 0, 0, 0);
+ const date2 = date.toISOString();
+ const result = isDifferentDay(date1, date2);
+ expect(result).toBe(false);
+ });
+
+ it('returns true for different dates', () => {
+ const date1 = new Date('2024-03-15');
+ date1.setHours(10, 0, 0, 0);
+ const date2 = new Date('2024-03-14');
+ date2.setHours(22, 0, 0, 0);
+ const result = isDifferentDay(date1.toISOString(), date2.toISOString());
+ expect(result).toBe(true);
+ });
+
+ it('returns false for same date at midnight and end of day', () => {
+ const date = new Date('2024-03-15');
+ date.setHours(0, 0, 0, 0);
+ const date1 = date.toISOString();
+ date.setHours(23, 59, 59, 999);
+ const date2 = date.toISOString();
+ const result = isDifferentDay(date1, date2);
+ expect(result).toBe(false);
+ });
+
+ it('returns true for consecutive days', () => {
+ const date1 = new Date('2024-03-15');
+ date1.setHours(0, 0, 0, 0);
+ const date2 = new Date('2024-03-16');
+ date2.setHours(0, 0, 0, 0);
+ const result = isDifferentDay(date1.toISOString(), date2.toISOString());
+ expect(result).toBe(true);
+ });
+
+ it('returns true for dates in different months', () => {
+ const date1 = new Date('2024-03-31');
+ date1.setHours(12, 0, 0, 0);
+ const date2 = new Date('2024-04-01');
+ date2.setHours(12, 0, 0, 0);
+ const result = isDifferentDay(date1.toISOString(), date2.toISOString());
+ expect(result).toBe(true);
+ });
+
+ it('returns true for dates in different years', () => {
+ const date1 = new Date('2023-12-31');
+ date1.setHours(23, 59, 59, 999);
+ const date2 = new Date('2024-01-01');
+ date2.setHours(0, 0, 0, 0);
+ const result = isDifferentDay(date1.toISOString(), date2.toISOString());
+ expect(result).toBe(true);
+ });
+
+ it('handles date strings in various formats', () => {
+ const date = new Date('2024-03-15');
+ const result = isDifferentDay(date.toISOString(), date.toISOString());
+ expect(result).toBe(false);
+ });
+
+ it('returns false when both dates are same', () => {
+ const date = '2024-06-20T10:30:00Z';
+ const result = isDifferentDay(date, date);
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/src/__tests__/utils/fcm.test.ts b/src/__tests__/utils/fcm.test.ts
new file mode 100644
index 000000000..a81bff469
--- /dev/null
+++ b/src/__tests__/utils/fcm.test.ts
@@ -0,0 +1,65 @@
+import { Platform } from 'react-native';
+
+import Constants from 'expo-constants';
+import * as Device from 'expo-device';
+
+import { getFcmToken } from '@/utils/fcm';
+
+jest.mock('react-native', () => ({
+ Platform: {
+ OS: 'android',
+ },
+}));
+jest.mock('expo-device', () => ({
+ isDevice: true,
+}));
+jest.mock('expo-constants', () => ({
+ __esModule: true,
+ default: {
+ executionEnvironment: 'standalone',
+ },
+}));
+jest.mock('@react-native-firebase/messaging', () => ({
+ __esModule: true,
+ getMessaging: jest.fn(() => ({})),
+ getToken: jest.fn(() => Promise.resolve('test-fcm-token-123')),
+}));
+
+describe('getFcmToken', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (Device as { isDevice: boolean }).isDevice = true;
+ (Constants as { executionEnvironment: string }).executionEnvironment = 'standalone';
+ (Platform as { OS: string }).OS = 'android';
+ });
+
+ it('returns null on non-Android platforms', async () => {
+ (Platform as { OS: string }).OS = 'ios';
+ const result = await getFcmToken();
+ expect(result).toBeNull();
+ });
+
+ it('returns null when not a physical device', async () => {
+ (Device as { isDevice: boolean }).isDevice = false;
+ const result = await getFcmToken();
+ expect(result).toBeNull();
+ });
+
+ it('returns null in Expo Go environment', async () => {
+ (Constants as { executionEnvironment: string }).executionEnvironment = 'storeClient';
+ const result = await getFcmToken();
+ expect(result).toBeNull();
+ });
+
+ it('returns token when all conditions are met', async () => {
+ const result = await getFcmToken();
+ expect(result).toBe('test-fcm-token-123');
+ });
+
+ it('returns null when getToken fails', async () => {
+ const { getToken } = require('@react-native-firebase/messaging');
+ getToken.mockRejectedValueOnce(new Error('Firebase error'));
+ const result = await getFcmToken();
+ expect(result).toBeNull();
+ });
+});
diff --git a/src/__tests__/utils/navigation/isViewingSameProfile.test.ts b/src/__tests__/utils/navigation/isViewingSameProfile.test.ts
new file mode 100644
index 000000000..a8cdffa2a
--- /dev/null
+++ b/src/__tests__/utils/navigation/isViewingSameProfile.test.ts
@@ -0,0 +1,73 @@
+import { navigationRef } from '@/navigation/navigationRef';
+import { isViewingSameProfile } from '@/utils/navigation/isViewingSameProfile';
+import { BOTTOM_TABS, PROFILE_TABS } from '@/utils/navigation/routeNames';
+
+jest.mock('@/navigation/navigationRef', () => ({
+ navigationRef: {
+ isReady: jest.fn(),
+ getCurrentRoute: jest.fn(),
+ },
+}));
+
+const mockedNavigationRef = navigationRef as jest.Mocked;
+
+describe('isViewingSameProfile', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns false when username is null or undefined', () => {
+ expect(isViewingSameProfile(null)).toBe(false);
+ expect(isViewingSameProfile(undefined)).toBe(false);
+ });
+
+ it('returns false when navigation is not ready', () => {
+ mockedNavigationRef.isReady.mockReturnValue(false);
+ expect(isViewingSameProfile('testuser')).toBe(false);
+ });
+
+ it('returns false when username is empty after trim', () => {
+ mockedNavigationRef.isReady.mockReturnValue(true);
+ expect(isViewingSameProfile(' ')).toBe(false);
+ });
+
+ it('returns false when not on a profile tab route', () => {
+ mockedNavigationRef.isReady.mockReturnValue(true);
+ mockedNavigationRef.getCurrentRoute.mockReturnValue({
+ key: 'home-1',
+ name: BOTTOM_TABS.HOME,
+ } as unknown as ReturnType);
+ expect(isViewingSameProfile('testuser')).toBe(false);
+ });
+
+ it('returns false when current route has no username param', () => {
+ mockedNavigationRef.isReady.mockReturnValue(true);
+ mockedNavigationRef.getCurrentRoute.mockReturnValue({
+ key: 'profile-1',
+ name: PROFILE_TABS.POSTS,
+ params: {},
+ } as unknown as ReturnType);
+ expect(isViewingSameProfile('testuser')).toBe(false);
+ });
+
+ it('returns true when viewing the same profile (case-insensitive)', () => {
+ mockedNavigationRef.isReady.mockReturnValue(true);
+ mockedNavigationRef.getCurrentRoute.mockReturnValue({
+ key: 'profile-1',
+ name: PROFILE_TABS.POSTS,
+ params: { username: 'TestUser' },
+ } as unknown as ReturnType);
+ expect(isViewingSameProfile('testuser')).toBe(true);
+ expect(isViewingSameProfile('TESTUSER')).toBe(true);
+ });
+
+ it('returns false when viewing a different profile', () => {
+ mockedNavigationRef.isReady.mockReturnValue(true);
+ mockedNavigationRef.getCurrentRoute.mockReturnValue({
+ key: 'profile-1',
+ name: PROFILE_TABS.POSTS,
+ params: { username: 'otheruser' },
+ } as unknown as ReturnType);
+ expect(isViewingSameProfile('testuser')).toBe(false);
+ });
+});
diff --git a/src/__tests__/utils/updateSearchCache.test.ts b/src/__tests__/utils/updateSearchCache.test.ts
new file mode 100644
index 000000000..89ea040e2
--- /dev/null
+++ b/src/__tests__/utils/updateSearchCache.test.ts
@@ -0,0 +1,361 @@
+import { QueryClient } from '@tanstack/react-query';
+
+import { updateSearchUsersCache } from '@/utils/updateSearchCache';
+
+import type { SearchUsersResult } from '@/services/search';
+import type { SearchUser } from '@/types/search';
+
+const createMockUser = (overrides: Partial = {}): SearchUser => ({
+ username: 'testuser',
+ displayName: 'Test User',
+ avatarUrl: 'https://example.com/avatar.jpg',
+ bannerUrl: null,
+ bio: 'Test bio',
+ relationship: {
+ following: false,
+ follower: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
+ bioEntities: null,
+ ...overrides,
+});
+
+const createMockSearchUsersResult = (users: SearchUser[]): SearchUsersResult => ({
+ success: true,
+ data: { users },
+});
+
+describe('updateSearchUsersCache', () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ queryClient.clear();
+ });
+
+ const setupInfiniteSearchResults = (users: SearchUser[]) => {
+ const queryKey = ['search', 'people', 'results', 'test', 'anyone', true];
+ queryClient.setQueryData(queryKey, {
+ pages: [createMockSearchUsersResult(users)],
+ pageParams: [null],
+ });
+ return queryKey;
+ };
+
+ it('should update relationship for matching user in search results', () => {
+ const user = createMockUser({ username: 'john_doe', relationship: undefined });
+ const queryKey = setupInfiniteSearchResults([user]);
+
+ updateSearchUsersCache(queryClient, 'john_doe', { following: true });
+
+ const data = queryClient.getQueryData(queryKey) as {
+ pages: SearchUsersResult[];
+ pageParams: (string | null)[];
+ };
+ expect(data.pages[0].data.users[0].relationship).toEqual({ following: true });
+ });
+
+ it('should merge relationship update with existing relationship', () => {
+ const user = createMockUser({
+ username: 'john_doe',
+ relationship: { following: false, muted: false },
+ });
+ const queryKey = setupInfiniteSearchResults([user]);
+
+ updateSearchUsersCache(queryClient, 'john_doe', { following: true });
+
+ const data = queryClient.getQueryData(queryKey) as {
+ pages: SearchUsersResult[];
+ pageParams: (string | null)[];
+ };
+ expect(data.pages[0].data.users[0].relationship).toEqual({
+ following: true,
+ muted: false,
+ });
+ });
+
+ it('should not modify other users in search results', () => {
+ const user1 = createMockUser({
+ username: 'john_doe',
+ relationship: { following: false },
+ });
+ const user2 = createMockUser({
+ username: 'jane_doe',
+ relationship: { following: false },
+ });
+ const queryKey = setupInfiniteSearchResults([user1, user2]);
+
+ updateSearchUsersCache(queryClient, 'john_doe', { following: true });
+
+ const data = queryClient.getQueryData(queryKey) as {
+ pages: SearchUsersResult[];
+ pageParams: (string | null)[];
+ };
+ expect(data.pages[0].data.users[0].relationship).toEqual({ following: true });
+ expect(data.pages[0].data.users[1].relationship).toEqual({ following: false });
+ });
+
+ it('should handle multiple pages in infinite query', () => {
+ const queryKey = ['search', 'people', 'results', 'test', 'anyone', true];
+
+ queryClient.setQueryData(queryKey, {
+ pages: [
+ createMockSearchUsersResult([
+ createMockUser({ username: 'user1', relationship: undefined }),
+ ]),
+ createMockSearchUsersResult([
+ createMockUser({ username: 'user2', relationship: undefined }),
+ ]),
+ ],
+ pageParams: [null, 'cursor1'],
+ });
+
+ updateSearchUsersCache(queryClient, 'user2', { blocking: true });
+
+ const data = queryClient.getQueryData(queryKey) as {
+ pages: SearchUsersResult[];
+ pageParams: (string | null)[];
+ };
+ expect(data.pages[0].data.users[0].relationship).toBeUndefined();
+ expect(data.pages[1].data.users[0].relationship).toEqual({ blocking: true });
+ });
+
+ it('should do nothing when user is not found in search results', () => {
+ const user = createMockUser({ username: 'john_doe', relationship: undefined });
+ const queryKey = setupInfiniteSearchResults([user]);
+
+ updateSearchUsersCache(queryClient, 'nonexistent_user', { following: true });
+
+ const data = queryClient.getQueryData(queryKey) as {
+ pages: SearchUsersResult[];
+ pageParams: (string | null)[];
+ };
+ expect(data.pages[0].data.users[0].relationship).toBeUndefined();
+ });
+
+ it('should skip queries with no data', () => {
+ const queryKey = ['search', 'people', 'results', 'test', 'anyone', true];
+ queryClient.setQueryData(queryKey, undefined);
+
+ expect(() => {
+ updateSearchUsersCache(queryClient, 'john_doe', { following: true });
+ }).not.toThrow();
+ });
+
+ const setupSuggestionsQuery = (users: SearchUser[]) => {
+ const queryKey = ['search', 'people', 'suggestions', 'test'];
+ queryClient.setQueryData(queryKey, createMockSearchUsersResult(users));
+ return queryKey;
+ };
+
+ it('should update relationship for matching user in suggestions', () => {
+ const user = createMockUser({ username: 'suggested_user', relationship: undefined });
+ const queryKey = setupSuggestionsQuery([user]);
+
+ updateSearchUsersCache(queryClient, 'suggested_user', { muted: true });
+
+ const data = queryClient.getQueryData(queryKey) as SearchUsersResult;
+ expect(data.data.users[0].relationship).toEqual({ muted: true });
+ });
+
+ it('should merge relationship update with existing relationship in suggestions', () => {
+ const user = createMockUser({
+ username: 'suggested_user',
+ relationship: { following: true, blocking: false },
+ });
+ const queryKey = setupSuggestionsQuery([user]);
+
+ updateSearchUsersCache(queryClient, 'suggested_user', { muted: true });
+
+ const data = queryClient.getQueryData(queryKey) as SearchUsersResult;
+ expect(data.data.users[0].relationship).toEqual({
+ following: true,
+ blocking: false,
+ muted: true,
+ });
+ });
+
+ it('should handle suggestions with null data', () => {
+ const queryKey = ['search', 'people', 'suggestions', 'test'];
+ queryClient.setQueryData(queryKey, { status: 'success', data: null });
+
+ expect(() => {
+ updateSearchUsersCache(queryClient, 'suggested_user', { following: true });
+ }).not.toThrow();
+ });
+
+ it('should handle suggestions with null users', () => {
+ const queryKey = ['search', 'people', 'suggestions', 'test'];
+ queryClient.setQueryData(queryKey, { status: 'success', data: { users: null } });
+
+ expect(() => {
+ updateSearchUsersCache(queryClient, 'suggested_user', { following: true });
+ }).not.toThrow();
+ });
+
+ const setupTopPeopleQuery = (users: SearchUser[]) => {
+ const queryKey = ['search', 'people', 'top', 'test'];
+ queryClient.setQueryData(queryKey, createMockSearchUsersResult(users));
+ return queryKey;
+ };
+
+ it('should update relationship for matching user in top people', () => {
+ const user = createMockUser({ username: 'top_user', relationship: undefined });
+ const queryKey = setupTopPeopleQuery([user]);
+
+ updateSearchUsersCache(queryClient, 'top_user', { following: true });
+
+ const data = queryClient.getQueryData(queryKey) as SearchUsersResult;
+ expect(data.data.users[0].relationship).toEqual({ following: true });
+ });
+
+ it('should merge relationship update with existing relationship in top people', () => {
+ const user = createMockUser({
+ username: 'top_user',
+ relationship: { follower: true },
+ });
+ const queryKey = setupTopPeopleQuery([user]);
+
+ updateSearchUsersCache(queryClient, 'top_user', { blocking: true });
+
+ const data = queryClient.getQueryData(queryKey) as SearchUsersResult;
+ expect(data.data.users[0].relationship).toEqual({
+ follower: true,
+ blocking: true,
+ });
+ });
+
+ it('should handle top people with null data', () => {
+ const queryKey = ['search', 'people', 'top', 'test'];
+ queryClient.setQueryData(queryKey, { status: 'success', data: null });
+
+ expect(() => {
+ updateSearchUsersCache(queryClient, 'top_user', { following: true });
+ }).not.toThrow();
+ });
+
+ it('should update all query types simultaneously', () => {
+ const resultsKey = ['search', 'people', 'results', 'query', 'anyone', true];
+ const suggestionsKey = ['search', 'people', 'suggestions', 'query'];
+ const topKey = ['search', 'people', 'top', 'query'];
+
+ const user = createMockUser({ username: 'multi_user', relationship: undefined });
+
+ queryClient.setQueryData(resultsKey, {
+ pages: [createMockSearchUsersResult([user])],
+ pageParams: [null],
+ });
+ queryClient.setQueryData(suggestionsKey, createMockSearchUsersResult([{ ...user }]));
+ queryClient.setQueryData(topKey, createMockSearchUsersResult([{ ...user }]));
+
+ updateSearchUsersCache(queryClient, 'multi_user', { following: true, muted: false });
+
+ const resultsData = queryClient.getQueryData(resultsKey) as {
+ pages: SearchUsersResult[];
+ pageParams: (string | null)[];
+ };
+ const suggestionsData = queryClient.getQueryData(suggestionsKey) as SearchUsersResult;
+ const topData = queryClient.getQueryData(topKey) as SearchUsersResult;
+
+ const expectedRelationship = { following: true, muted: false };
+ expect(resultsData.pages[0].data.users[0].relationship).toEqual(expectedRelationship);
+ expect(suggestionsData.data.users[0].relationship).toEqual(expectedRelationship);
+ expect(topData.data.users[0].relationship).toEqual(expectedRelationship);
+ });
+
+ it('should not affect queries with different key structure', () => {
+ const unrelatedKey = ['users', 'profile', 'john_doe'];
+ const userData = { username: 'john_doe', relationship: { following: false } };
+ queryClient.setQueryData(unrelatedKey, userData);
+
+ updateSearchUsersCache(queryClient, 'john_doe', { following: true });
+
+ const data = queryClient.getQueryData(unrelatedKey);
+ expect(data).toEqual(userData);
+ });
+
+ it('should only match search people queries', () => {
+ const tweetsKey = ['search', 'tweets', 'test'];
+ const tweetsData = { tweets: [] };
+ queryClient.setQueryData(tweetsKey, tweetsData);
+
+ updateSearchUsersCache(queryClient, 'john_doe', { following: true });
+
+ const data = queryClient.getQueryData(tweetsKey);
+ expect(data).toEqual(tweetsData);
+ });
+
+ it('should handle blocking relationship update', () => {
+ const user = createMockUser({ username: 'blocked_user', relationship: undefined });
+ const queryKey = ['search', 'people', 'suggestions', 'test'];
+ queryClient.setQueryData(queryKey, createMockSearchUsersResult([user]));
+
+ updateSearchUsersCache(queryClient, 'blocked_user', { blocking: true });
+
+ const data = queryClient.getQueryData(queryKey) as SearchUsersResult;
+ expect(data.data.users[0].relationship).toEqual({ blocking: true });
+ });
+
+ it('should handle muted relationship update', () => {
+ const user = createMockUser({ username: 'muted_user', relationship: undefined });
+ const queryKey = ['search', 'people', 'suggestions', 'test'];
+ queryClient.setQueryData(queryKey, createMockSearchUsersResult([user]));
+
+ updateSearchUsersCache(queryClient, 'muted_user', { muted: true });
+
+ const data = queryClient.getQueryData(queryKey) as SearchUsersResult;
+ expect(data.data.users[0].relationship).toEqual({ muted: true });
+ });
+
+ it('should handle blockedBy relationship update', () => {
+ const user = createMockUser({ username: 'blocking_user', relationship: undefined });
+ const queryKey = ['search', 'people', 'suggestions', 'test'];
+ queryClient.setQueryData(queryKey, createMockSearchUsersResult([user]));
+
+ updateSearchUsersCache(queryClient, 'blocking_user', { blockedBy: true });
+
+ const data = queryClient.getQueryData(queryKey) as SearchUsersResult;
+ expect(data.data.users[0].relationship).toEqual({ blockedBy: true });
+ });
+
+ it('should handle follower relationship update', () => {
+ const user = createMockUser({ username: 'follower_user', relationship: undefined });
+ const queryKey = ['search', 'people', 'suggestions', 'test'];
+ queryClient.setQueryData(queryKey, createMockSearchUsersResult([user]));
+
+ updateSearchUsersCache(queryClient, 'follower_user', { follower: true });
+
+ const data = queryClient.getQueryData(queryKey) as SearchUsersResult;
+ expect(data.data.users[0].relationship).toEqual({ follower: true });
+ });
+
+ it('should handle multiple relationship fields in one update', () => {
+ const user = createMockUser({ username: 'complex_user', relationship: undefined });
+ const queryKey = ['search', 'people', 'suggestions', 'test'];
+ queryClient.setQueryData(queryKey, createMockSearchUsersResult([user]));
+
+ updateSearchUsersCache(queryClient, 'complex_user', {
+ following: true,
+ muted: false,
+ blocking: false,
+ });
+
+ const data = queryClient.getQueryData(queryKey) as SearchUsersResult;
+ expect(data.data.users[0].relationship).toEqual({
+ following: true,
+ muted: false,
+ blocking: false,
+ });
+ });
+});
diff --git a/src/components/messages/MessageInput.tsx b/src/components/messages/MessageInput.tsx
index f8139deeb..5db04a8d8 100644
--- a/src/components/messages/MessageInput.tsx
+++ b/src/components/messages/MessageInput.tsx
@@ -1,3 +1,5 @@
+/* eslint-disable react-native/no-inline-styles */
+
import {
Image,
KeyboardAvoidingView,
@@ -21,6 +23,8 @@ type MessageInputProps = {
onSend: () => void;
mediaUri: string | null;
onMediaSelect: (uri: string | null, mediaType?: 'photo' | 'video') => void;
+ accessibilityLabel?: string;
+ disabled?: boolean;
};
const MessageInput = ({
@@ -29,6 +33,8 @@ const MessageInput = ({
onSend,
mediaUri,
onMediaSelect,
+ accessibilityLabel,
+ disabled = false,
}: MessageInputProps) => {
const { theme } = useTheme();
const palette = colors[theme];
@@ -40,7 +46,12 @@ const MessageInput = ({
});
const keyboardVerticalOffset = Platform.OS === 'ios' ? bottom + 8 : 0;
+ const inputStyle = { color: palette.foreground, maxHeight: 100, minHeight: 20 };
+ const containerStyle = { backgroundColor: palette.search, minHeight: 44 };
+
const handlePickImage = async () => {
+ if (disabled) return;
+
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images', 'videos'],
allowsMultipleSelection: false,
@@ -57,6 +68,7 @@ const MessageInput = ({
};
const handleRemoveMedia = () => {
+ if (disabled) return;
onMediaSelect(null);
};
@@ -66,10 +78,7 @@ const MessageInput = ({
keyboardVerticalOffset={keyboardVerticalOffset}
>
-
+
{mediaUri && (
@@ -78,6 +87,7 @@ const MessageInput = ({
onPress={handleRemoveMedia}
className="absolute top-1 right-1 bg-black/70 rounded-full p-1"
accessibilityLabel="Remove image"
+ disabled={disabled}
>
@@ -88,23 +98,30 @@ const MessageInput = ({
-
+
{(value.trim().length > 0 || mediaUri) && (
@@ -112,7 +129,10 @@ const MessageInput = ({
accessibilityRole="button"
onPress={onSend}
testID="send-button"
- className="ms-2 me-1 p-2.5 rounded-full bg-primary"
+ accessibilityLabel="send-message-button"
+ disabled={disabled}
+ style={{ opacity: disabled ? 0.5 : 1 }}
+ className="mb-1 ms-2 p-2.5 rounded-full bg-primary self-end"
>
diff --git a/src/components/messages/MessageItem.tsx b/src/components/messages/MessageItem.tsx
index 13250bbd8..11db98737 100644
--- a/src/components/messages/MessageItem.tsx
+++ b/src/components/messages/MessageItem.tsx
@@ -1,5 +1,5 @@
/* eslint-disable react-native/no-inline-styles */
-import { useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
Image,
@@ -40,6 +40,7 @@ type MessageItemProps = {
lastSeenMessageId?: string | null;
conversationId: string;
onDelete?: (messageId: string) => void;
+ disabled?: boolean;
};
const MessageItem = ({
@@ -49,6 +50,7 @@ const MessageItem = ({
lastSeenMessageId,
conversationId,
onDelete,
+ disabled = false,
}: MessageItemProps) => {
const { theme } = useTheme();
const palette = colors[theme];
@@ -64,7 +66,11 @@ const MessageItem = ({
const [showReactionDetails, setShowReactionDetails] = useState(false);
const [showVideoModal, setShowVideoModal] = useState(false);
const [messagePosition, setMessagePosition] = useState({ y: 0 });
+
const lastTapRef = useRef(0);
+
+ const singleTapTimer = useRef | null>(null);
+
const messageRef = useRef(null);
const d = new Date(item.createdAt);
@@ -75,6 +81,12 @@ const MessageItem = ({
if (h === 0) h = 12;
const itemTime = `${h}:${m.toString().padStart(2, '0')} ${ampm}`;
+ useEffect(() => {
+ return () => {
+ if (singleTapTimer.current) clearTimeout(singleTapTimer.current);
+ };
+ }, []);
+
const handleLongPress = () => {
if (isOptimistic) return;
setShowOptionsModal(true);
@@ -85,21 +97,35 @@ const MessageItem = ({
};
const handleReact = () => {
+ if (disabled) return;
messageRef.current?.measureInWindow((_x, y) => {
setMessagePosition({ y });
setShowReactionPicker(true);
});
};
- const handleDoubleTap = () => {
+ const handleDoubleTap = (onSingleTap?: () => void) => {
+ if (disabled) return;
const now = Date.now();
- const DOUBLE_TAP_DELAY = 300;
+ const DOUBLE_TAP_DELAY = 200;
if (now - lastTapRef.current < DOUBLE_TAP_DELAY) {
+ if (singleTapTimer.current) {
+ clearTimeout(singleTapTimer.current);
+ singleTapTimer.current = null;
+ }
+
messageRef.current?.measureInWindow((_x, y) => {
setMessagePosition({ y });
setShowReactionPicker(true);
});
+ } else {
+ if (onSingleTap) {
+ singleTapTimer.current = setTimeout(() => {
+ onSingleTap();
+ singleTapTimer.current = null;
+ }, DOUBLE_TAP_DELAY);
+ }
}
lastTapRef.current = now;
};
@@ -187,6 +213,9 @@ const MessageItem = ({
};
const getStatusText = () => {
+ if (item.failed) {
+ return null;
+ }
if (isOptimistic) {
if (item.mediaUrl) return 'Uploading';
return 'Sending';
@@ -223,12 +252,10 @@ const MessageItem = ({
return url.endsWith('.mp4') || url.endsWith('.mov') || url.includes('video');
};
- const handleVideoPress = () => {
- setShowVideoModal(true);
- };
-
- const handleImagePress = () => {
- if (item.mediaUrl && !isVideo(item.mediaUrl)) {
+ const openMedia = () => {
+ if (isVideo(item.mediaUrl)) {
+ setShowVideoModal(true);
+ } else if (item.mediaUrl) {
navigation.navigate(ROOT.MESSAGE_IMAGE, { uri: item.mediaUrl });
}
};
@@ -240,7 +267,7 @@ const MessageItem = ({
return (
handleDoubleTap()}
onLongPress={handleLongPress}
disabled={isOptimistic}
>
@@ -252,7 +279,7 @@ const MessageItem = ({
{item.mediaUrl && (
{isVideo(item.mediaUrl) ? (
-
+ handleDoubleTap(openMedia)} onLongPress={handleLongPress}>
) : (
-
+ handleDoubleTap(openMedia)} onLongPress={handleLongPress}>
-
- {getStatusText()}
-
+ {item.failed ? (
+ Failed
+ ) : (
+
+ {getStatusText()}
+
+ )}
)}
@@ -385,6 +416,7 @@ const MessageItem = ({
senderReaction={item.reactions.sender}
receiverReaction={item.reactions.receiver}
onUndoReaction={handleUndoReaction}
+ disabled={disabled}
/>
{isVideo(item.mediaUrl) && (
-
+
true}
@@ -66,7 +66,11 @@ export const MessageOptionsModal = ({
animationType="fade"
onRequestClose={() => setShowConfirmation(false)}
>
- setShowConfirmation(false)}>
+ setShowConfirmation(false)}
+ >
true}
@@ -74,10 +78,12 @@ export const MessageOptionsModal = ({
Delete Message?
+
This message will be deleted for you.{'\n'}Other people in the conversation will be
able to see it.
+
+
{
diff --git a/src/components/messages/NewMessageComposer.tsx b/src/components/messages/NewMessageComposer.tsx
index 58804bcc6..135fe2183 100644
--- a/src/components/messages/NewMessageComposer.tsx
+++ b/src/components/messages/NewMessageComposer.tsx
@@ -20,6 +20,7 @@ import {
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { createOrGetConversationByUsername, dmKeys, listConversations } from '@/services/dm';
import { searchUsers } from '@/services/search';
+import { useUserStore } from '@/stores/userStore';
export type Recipient = {
key: string;
@@ -80,32 +81,37 @@ export default function NewMessageComposer({
const recentRecipients: Recipient[] = useMemo(() => {
const base = conversations || [];
- return base.slice(0, 20).map((c) => {
- const participant = c.participant as {
- username: string;
- displayName: string;
- avatarUrl: string | null;
- };
-
- return {
- key: `c:${c.id}`,
- username: participant.username,
- displayName: participant.displayName,
- avatarUrl: participant.avatarUrl ?? null,
- conversationId: c.id,
- };
- });
+ return base
+ .filter((c) => c.participant.username !== useUserStore.getState().user.username)
+ .slice(0, 20)
+ .map((c) => {
+ const participant = c.participant as {
+ username: string;
+ displayName: string;
+ avatarUrl: string | null;
+ };
+
+ return {
+ key: `c:${c.id}`,
+ username: participant.username,
+ displayName: participant.displayName,
+ avatarUrl: participant.avatarUrl ?? null,
+ conversationId: c.id,
+ };
+ });
}, [conversations]);
const searchRecipients: Recipient[] = useMemo(() => {
const users = search?.users || [];
- return users.map((u: { username: string; displayName: string; avatarUrl: string | null }) => ({
- key: `u:${u.username}`,
- username: u.username,
- displayName: u.displayName,
- avatarUrl: u.avatarUrl ?? null,
- }));
+ return users
+ .filter((u: { username: string }) => u.username !== useUserStore.getState().user.username)
+ .map((u: { username: string; displayName: string; avatarUrl: string | null }) => ({
+ key: `u:${u.username}`,
+ username: u.username,
+ displayName: u.displayName,
+ avatarUrl: u.avatarUrl ?? null,
+ }));
}, [search]);
const suggestions: Recipient[] = debouncedQuery.length > 0 ? searchRecipients : recentRecipients;
@@ -188,6 +194,7 @@ export default function NewMessageComposer({
placeholderTextColor="#888"
className="text-foreground py-2"
autoFocus
+ accessibilityLabel="new-message-search-input"
/>
@@ -214,6 +221,7 @@ export default function NewMessageComposer({
startConversationForRecipient(recipient)}
testID={`recipient-${recipient.username}`}
+ accessibilityLabel="new-message-recipient"
className="px-4 py-3 flex-row items-center"
>
-
+
{recipient.displayName}
-
+
@{recipient.username}
diff --git a/src/components/messages/NewMessageSheet.tsx b/src/components/messages/NewMessageSheet.tsx
index a011c0cdb..031ccf3a7 100644
--- a/src/components/messages/NewMessageSheet.tsx
+++ b/src/components/messages/NewMessageSheet.tsx
@@ -90,6 +90,7 @@ export default function NewMessageSheet({ isOpen, onClose }: NewMessageSheetProp
style={newMessageSheetStyles.backdrop}
onPress={onClose}
testID="new-message-backdrop"
+ accessibilityLabel="new-message-backdrop"
/>
void;
+ disabled?: boolean;
};
export const ReactionDetailsDrawer = ({
@@ -28,6 +29,7 @@ export const ReactionDetailsDrawer = ({
senderReaction,
receiverReaction,
onUndoReaction,
+ disabled = false,
}: ReactionDetailsDrawerProps) => {
const { theme } = useTheme();
const palette = colors[theme];
@@ -86,11 +88,17 @@ export const ReactionDetailsDrawer = ({
showUsername={true}
/>
-
-
- Undo
-
-
+ {!disabled && (
+
+
+ Undo
+
+
+ )}
)}
diff --git a/src/components/messages/ReactionPicker.tsx b/src/components/messages/ReactionPicker.tsx
index bcd39c458..25a7bb473 100644
--- a/src/components/messages/ReactionPicker.tsx
+++ b/src/components/messages/ReactionPicker.tsx
@@ -58,7 +58,7 @@ export const ReactionPicker = ({
return (
-
+
);
})}
-
+
diff --git a/src/components/messages/TypingIndicator.tsx b/src/components/messages/TypingIndicator.tsx
index f9843a110..34468389c 100644
--- a/src/components/messages/TypingIndicator.tsx
+++ b/src/components/messages/TypingIndicator.tsx
@@ -55,7 +55,7 @@ const TypingIndicator = () => {
}, [dot1Opacity, dot2Opacity, dot3Opacity]);
return (
-
+
{
style={{ backgroundColor: palette.search }}
>
diff --git a/src/components/notifications/NotificationsList.tsx b/src/components/notifications/NotificationsList.tsx
index 0994fa7f9..eafe471b2 100644
--- a/src/components/notifications/NotificationsList.tsx
+++ b/src/components/notifications/NotificationsList.tsx
@@ -97,6 +97,9 @@ export function NotificationsList({
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
onEndReachedThreshold={0.3}
+ maintainVisibleContentPosition={{
+ autoscrollToTopThreshold: 10,
+ }}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooter}
refreshControl={
diff --git a/src/components/notifications/types/FollowNotificationItem.tsx b/src/components/notifications/types/FollowNotificationItem.tsx
index f97ab2b70..451295ca2 100644
--- a/src/components/notifications/types/FollowNotificationItem.tsx
+++ b/src/components/notifications/types/FollowNotificationItem.tsx
@@ -57,7 +57,7 @@ export function FollowNotificationItem({
);
};
- const isSingle = totalCount === 1 && primaryActor;
+ const isSingle = totalCount <= 1 && primaryActor;
const handlePress = () => {
onPress(() => {
@@ -86,7 +86,12 @@ export function FollowNotificationItem({
-
+
@@ -96,6 +101,7 @@ export function FollowNotificationItem({
{primaryActor?.displayName}
diff --git a/src/components/notifications/types/TweetActionBar.tsx b/src/components/notifications/types/TweetActionBar.tsx
index 5aa09119b..04c13e887 100644
--- a/src/components/notifications/types/TweetActionBar.tsx
+++ b/src/components/notifications/types/TweetActionBar.tsx
@@ -1,16 +1,20 @@
-/* eslint-disable react-native/no-inline-styles */
-import { useState } from 'react';
+import { useLayoutEffect } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@expo/vector-icons/Ionicons';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useLikeTweetMutation, useRetweetTweetMutation } from '@/hooks/tweets/useTweetInteractions';
import { useTheme } from '@/hooks/useTheme';
-import { likeTweet, retweetTweet, unlikeTweet, unretweetTweet } from '@/services/tweets';
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
import { colors } from '@/utils/colorTheme';
import { formatCount } from '@/utils/formatCount';
-interface TweetActionBarProps {
+import type { Tweet } from '@/types/tweet';
+
+export interface TweetActionBarProps {
tweetId: string;
initialIsLiked: boolean;
initialIsRetweeted: boolean;
@@ -18,6 +22,19 @@ interface TweetActionBarProps {
initialRetweetCount: number;
initialReplyCount: number;
onReplyPress?: () => void;
+ onRetweetPress?: () => void;
+ onLongPressLike?: (id: string) => void;
+ onLongPressRetweet?: (id: string) => void;
+ onSharePress?: () => void;
+ showShare?: boolean;
+ iconColor?: string;
+ textColor?: string;
+ testIdPrefix?: string;
+ activeColor?: {
+ like?: string;
+ retweet?: string;
+ };
+ iconSize?: number;
}
export function TweetActionBar({
@@ -28,83 +45,117 @@ export function TweetActionBar({
initialRetweetCount,
initialReplyCount,
onReplyPress,
+ onRetweetPress,
+ onLongPressLike,
+ onLongPressRetweet,
+ onSharePress,
+ iconColor,
+ textColor,
+ testIdPrefix = 'notification',
+ activeColor,
+ iconSize = 18,
+ showShare = false,
}: TweetActionBarProps) {
const { theme } = useTheme();
const currentColors = colors[theme];
-
- const [isLiked, setIsLiked] = useState(initialIsLiked);
- const [isRetweeted, setIsRetweeted] = useState(initialIsRetweeted);
- const [likeCount, setLikeCount] = useState(initialLikeCount);
- const [retweetCount, setRetweetCount] = useState(initialRetweetCount);
-
- const toggleLike = async () => {
- const previousLiked = isLiked;
- const previousCount = likeCount;
-
- setIsLiked(!isLiked);
- setLikeCount(isLiked ? Math.max(0, likeCount - 1) : likeCount + 1);
-
- try {
- if (!previousLiked) {
- await likeTweet(tweetId);
- } else {
- await unlikeTweet(tweetId);
- }
- } catch {
- setIsLiked(previousLiked);
- setLikeCount(previousCount);
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
+
+ useLayoutEffect(() => {
+ if (!tweetCache.hasTweet(tweetId)) {
+ const minimalTweet: Partial = {
+ id: tweetId,
+ isLiked: initialIsLiked,
+ isRetweeted: initialIsRetweeted,
+ likeCount: initialLikeCount,
+ retweetCount: initialRetweetCount,
+ replyCount: initialReplyCount,
+ };
+ tweetCache.setTweet(minimalTweet as Tweet);
}
+ }, [
+ tweetId,
+ initialIsLiked,
+ initialIsRetweeted,
+ initialLikeCount,
+ initialRetweetCount,
+ initialReplyCount,
+ tweetCache,
+ ]);
+
+ // This subscribes to cache changes, doesn't do any logic
+ const { data: cachedTweet } = useQuery({
+ queryKey: queryKeys.tweet(tweetId),
+ // React Query queryFns must not return undefined
+ queryFn: () => tweetCache.getTweet(tweetId) ?? null,
+ staleTime: Infinity, // Never refetch, just use cache
+ gcTime: Infinity, // Keep in cache indefinitely
+ });
+
+ const isLiked = cachedTweet?.isLiked ?? initialIsLiked;
+ const isRetweeted = cachedTweet?.isRetweeted ?? initialIsRetweeted;
+ const likeCount = cachedTweet?.likeCount ?? initialLikeCount;
+ const retweetCount = cachedTweet?.retweetCount ?? initialRetweetCount;
+ const replyCount = cachedTweet?.replyCount ?? initialReplyCount;
+
+ const likeMutation = useLikeTweetMutation();
+ const retweetMutation = useRetweetTweetMutation();
+
+ const toggleLike = () => {
+ likeMutation.mutate({
+ tweetId,
+ like: !isLiked,
+ });
};
- const toggleRetweet = async () => {
- const previousRetweeted = isRetweeted;
- const previousCount = retweetCount;
-
- setIsRetweeted(!isRetweeted);
- setRetweetCount(isRetweeted ? Math.max(0, retweetCount - 1) : retweetCount + 1);
-
- try {
- if (!previousRetweeted) {
- await retweetTweet(tweetId);
- } else {
- await unretweetTweet(tweetId);
- }
- } catch {
- setIsRetweeted(previousRetweeted);
- setRetweetCount(previousCount);
- }
+ const toggleRetweet = () => {
+ retweetMutation.mutate({
+ tweetId,
+ retweet: !isRetweeted,
+ });
};
+ const defaultIconColor = iconColor ?? currentColors.mutedForeground;
+ const defaultTextColor = textColor ?? currentColors.mutedForeground;
+ const likeActiveColor = activeColor?.like ?? '#e0245e';
+ const retweetActiveColor = activeColor?.retweet ?? '#17BF63';
+
+ const makeTestId = (name: string) => (testIdPrefix ? `${testIdPrefix}-${name}` : name);
+
return (
-
-
- {formatCount(initialReplyCount)}
+
+
+ {formatCount(replyCount)}
onLongPressRetweet(tweetId) : undefined}
style={styles.actionButton}
- testID="notification-retweet-button"
+ testID={makeTestId('retweet-button')}
+ accessibilityLabel={makeTestId('retweet-button')}
>
{formatCount(retweetCount)}
@@ -113,20 +164,35 @@ export function TweetActionBar({
onLongPressLike(tweetId) : undefined}
style={styles.actionButton}
- testID="notification-like-button"
+ testID={makeTestId('like-button')}
+ accessibilityLabel={makeTestId('like-button')}
>
{formatCount(likeCount)}
+
+ {showShare && onSharePress ? (
+
+
+
+ ) : null}
);
}
diff --git a/src/components/notifications/types/TweetLikeNotificationItem.tsx b/src/components/notifications/types/TweetLikeNotificationItem.tsx
index b4b37a760..148ba2aac 100644
--- a/src/components/notifications/types/TweetLikeNotificationItem.tsx
+++ b/src/components/notifications/types/TweetLikeNotificationItem.tsx
@@ -33,9 +33,18 @@ export function TweetLikeNotificationItem({ notification, onPress }: Props) {
const rootNavigation = useRootNavigation();
const handleNotificationPress = () => {
- onPress(() => {
- navigation.navigate(TWEET.DETAIL, { tweetId: tweet.id });
- });
+ if (!tweet) return;
+ onPress(() => navigation.navigate(TWEET.DETAIL, { tweetId: tweet.id }));
+ };
+
+ const handleReplyPress = () => {
+ if (!tweet) return;
+ onPress(() =>
+ rootNavigation.navigate(ROOT.TWEET, {
+ screen: TWEET.DETAIL,
+ params: { tweetId: tweet.id, initialOpenComposer: true },
+ })
+ );
};
const handleActorPress = () => {
@@ -95,16 +104,24 @@ export function TweetLikeNotificationItem({ notification, onPress }: Props) {
);
return (
-
+
-
+
- {totalCount === 1 ? singleLine : multiLine}
+ {totalCount <= 1 ? singleLine : multiLine}
- {tweet.content && (
+ {tweet?.content ? (
- {tweet.content}
+ {tweet?.content || ''}
- {tweet.media && tweet.media.length > 0 && (
+ {tweet?.media && tweet.media.length > 0 && (
)}
- )}
-
+ ) : null}
+ {tweet ? (
+
+ ) : null}
);
diff --git a/src/components/notifications/types/TweetMentionNotificationItem.tsx b/src/components/notifications/types/TweetMentionNotificationItem.tsx
index b05ad7008..3d6b71616 100644
--- a/src/components/notifications/types/TweetMentionNotificationItem.tsx
+++ b/src/components/notifications/types/TweetMentionNotificationItem.tsx
@@ -32,9 +32,18 @@ export function TweetMentionNotificationItem({ notification, onPress }: Props) {
const rootNavigation = useRootNavigation();
const handleNotificationPress = () => {
- onPress(() => {
- navigation.navigate(TWEET.DETAIL, { tweetId: tweet.id });
- });
+ if (!tweet) return;
+ onPress(() => navigation.navigate(TWEET.DETAIL, { tweetId: tweet.id }));
+ };
+
+ const handleReplyPress = () => {
+ if (!tweet) return;
+ onPress(() =>
+ rootNavigation.navigate(ROOT.TWEET, {
+ screen: TWEET.DETAIL,
+ params: { tweetId: tweet.id, initialOpenComposer: true },
+ })
+ );
};
const handleActorPress = () => {
@@ -92,19 +101,23 @@ export function TweetMentionNotificationItem({ notification, onPress }: Props) {
mentioned you
-
- {tweet.content}
- {tweet.media && tweet.media.length > 0 && }
-
-
+ {tweet ? (
+ <>
+
+ {tweet.content || ''}
+ {tweet.media && tweet.media.length > 0 && }
+
+
+ >
+ ) : null}
);
diff --git a/src/components/notifications/types/TweetQuoteNotificationItem.tsx b/src/components/notifications/types/TweetQuoteNotificationItem.tsx
index 1e33498d4..ca56494e4 100644
--- a/src/components/notifications/types/TweetQuoteNotificationItem.tsx
+++ b/src/components/notifications/types/TweetQuoteNotificationItem.tsx
@@ -28,15 +28,24 @@ export function TweetQuoteNotificationItem({ notification, onPress }: Props) {
const { previewActors, totalCount } = notification.actorSummary;
const primaryActor = previewActors[0];
const quoteTweet = notification.tweetSummary.primaryTweet;
- const originalTweet = quoteTweet.quotedTweet;
+ const originalTweet = quoteTweet?.quotedTweet;
const displayName = primaryActor?.displayName ?? '';
const navigation = useNavigation>();
const rootNavigation = useRootNavigation();
const handleNotificationPress = () => {
- onPress(() => {
- navigation.navigate(TWEET.DETAIL, { tweetId: quoteTweet.id });
- });
+ if (!quoteTweet) return;
+ onPress(() => navigation.navigate(TWEET.DETAIL, { tweetId: quoteTweet.id }));
+ };
+
+ const handleReplyPress = () => {
+ if (!quoteTweet) return;
+ onPress(() =>
+ rootNavigation.navigate(ROOT.TWEET, {
+ screen: TWEET.DETAIL,
+ params: { tweetId: quoteTweet.id, initialOpenComposer: true },
+ })
+ );
};
const handleActorPress = () => {
@@ -93,16 +102,19 @@ export function TweetQuoteNotificationItem({ notification, onPress }: Props) {
};
return (
-
+
- {totalCount === 1 ? singleLine : multiLine}
+ {totalCount <= 1 ? singleLine : multiLine}
- {originalTweet && (
+ {quoteTweet && originalTweet && (
- {(!('isDeleted' in originalTweet) && originalTweet.author?.displayName) ??
- 'Original Tweet'}
+ {!('isDeleted' in originalTweet) && originalTweet.author?.displayName
+ ? originalTweet.author.displayName
+ : 'Original Tweet'}
{!('isDeleted' in originalTweet) && originalTweet.content ? (
- {originalTweet.content}
+ {originalTweet.content || ''}
) : null}
)}
- {quoteTweet.content && (
+ {quoteTweet?.content ? (
- {quoteTweet.content}
+ {quoteTweet?.content || ''}
- )}
- {quoteTweet.media && quoteTweet.media.length > 0 && (
+ ) : null}
+ {quoteTweet?.media && quoteTweet.media.length > 0 ? (
- )}
-
-
+ ) : null}
+ {quoteTweet ? (
+
+ ) : null}
);
diff --git a/src/components/notifications/types/TweetReplyNotificationItem.tsx b/src/components/notifications/types/TweetReplyNotificationItem.tsx
index f07d009b5..69dad788b 100644
--- a/src/components/notifications/types/TweetReplyNotificationItem.tsx
+++ b/src/components/notifications/types/TweetReplyNotificationItem.tsx
@@ -33,9 +33,18 @@ export function TweetReplyNotificationItem({ notification, onPress }: Props) {
const rootNavigation = useRootNavigation();
const handleNotificationPress = () => {
- onPress(() => {
- navigation.navigate(TWEET.DETAIL, { tweetId: tweet.id });
- });
+ if (!tweet) return;
+ onPress(() => navigation.navigate(TWEET.DETAIL, { tweetId: tweet.id }));
+ };
+
+ const handleReplyPress = () => {
+ if (!tweet) return;
+ onPress(() =>
+ rootNavigation.navigate(ROOT.TWEET, {
+ screen: TWEET.DETAIL,
+ params: { tweetId: tweet.id, initialOpenComposer: true },
+ })
+ );
};
const handleActorPress = () => {
@@ -102,28 +111,40 @@ export function TweetReplyNotificationItem({ notification, onPress }: Props) {
};
return (
-
+
-
+
- {totalCount === 1 ? singleLine : multiLine}
-
-
- {tweet.content}
- {tweet.media && tweet.media.length > 0 && }
+ {totalCount <= 1 ? singleLine : multiLine}
-
+ {tweet ? (
+ <>
+
+ {tweet?.content || ''}
+ {tweet?.media && tweet.media.length > 0 && }
+
+
+ >
+ ) : null}
);
diff --git a/src/components/notifications/types/TweetRetweetNotificationItem.tsx b/src/components/notifications/types/TweetRetweetNotificationItem.tsx
index 80437be8c..17c674303 100644
--- a/src/components/notifications/types/TweetRetweetNotificationItem.tsx
+++ b/src/components/notifications/types/TweetRetweetNotificationItem.tsx
@@ -32,9 +32,18 @@ export function TweetRetweetNotificationItem({ notification, onPress }: Props) {
const rootNavigation = useRootNavigation();
const handleNotificationPress = () => {
- onPress(() => {
- navigation.navigate(TWEET.DETAIL, { tweetId: tweet.id });
- });
+ if (!tweet) return;
+ onPress(() => navigation.navigate(TWEET.DETAIL, { tweetId: tweet.id }));
+ };
+
+ const handleReplyPress = () => {
+ if (!tweet) return;
+ onPress(() =>
+ rootNavigation.navigate(ROOT.TWEET, {
+ screen: TWEET.DETAIL,
+ params: { tweetId: tweet.id, initialOpenComposer: true },
+ })
+ );
};
const handleActorPress = () => {
@@ -101,36 +110,47 @@ export function TweetRetweetNotificationItem({ notification, onPress }: Props) {
};
return (
-
+
-
+
- {totalCount === 1 ? singleLine : multiLine}
+ {totalCount <= 1 ? singleLine : multiLine}
- {tweet.content && (
+ {tweet?.content ? (
- {tweet.content}
- {tweet.media && tweet.media.length > 0 && (
+ {tweet?.content || ''}
+ {tweet?.media && tweet.media.length > 0 && (
)}
- )}
-
+ ) : null}
+ {tweet ? (
+
+ ) : null}
);
diff --git a/src/components/profile/Banner.tsx b/src/components/profile/Banner.tsx
index 1e863759a..2f0e5a193 100644
--- a/src/components/profile/Banner.tsx
+++ b/src/components/profile/Banner.tsx
@@ -86,6 +86,7 @@ const ProfileBanner = ({ uri, username, displayName, isEdit, testID, profile }:
},
{
onError: (error) => {
+ setBlockModalVisible(false);
Alert.alert('Unable to unblock account', error.message || 'Please try again.');
},
}
@@ -103,6 +104,7 @@ const ProfileBanner = ({ uri, username, displayName, isEdit, testID, profile }:
},
{
onError: (error) => {
+ setMuteModalVisible(false);
Alert.alert('Unable to mute account', error.message || 'Please try again.');
},
}
@@ -120,6 +122,7 @@ const ProfileBanner = ({ uri, username, displayName, isEdit, testID, profile }:
>
navigation.goBack()}
>
@@ -133,6 +136,7 @@ const ProfileBanner = ({ uri, username, displayName, isEdit, testID, profile }:
setDropdownVisible(true)}
>
@@ -159,6 +163,7 @@ const ProfileBanner = ({ uri, username, displayName, isEdit, testID, profile }:
onPress={handleShare}
style={styles.modalButton}
testID="share-button"
+ accessibilityLabel="share-button"
/>
{/* you can't mute a blocked user */}
@@ -172,6 +177,7 @@ const ProfileBanner = ({ uri, username, displayName, isEdit, testID, profile }:
setMuteModalVisible(true);
}}
style={styles.modalButton}
+ accessibilityLabel={isMuted ? 'unmute-button' : 'mute-button'}
/>
)}
{!isAuthenticatedUser && (
@@ -184,6 +190,7 @@ const ProfileBanner = ({ uri, username, displayName, isEdit, testID, profile }:
setBlockModalVisible(true);
}}
style={styles.modalButton}
+ accessibilityLabel={isBlocking ? 'unblock-button' : 'block-button-menu'}
/>
)}
diff --git a/src/components/profile/ConnectionListItem.tsx b/src/components/profile/ConnectionListItem.tsx
index 39591ff51..dba2fae03 100644
--- a/src/components/profile/ConnectionListItem.tsx
+++ b/src/components/profile/ConnectionListItem.tsx
@@ -13,8 +13,8 @@ type ConnectionListItemProps = {
username: string;
avatarUrl?: string | null;
bio?: string | null;
- followsYou?: boolean | null;
- isFollowing?: boolean | null;
+ followsYou?: boolean;
+ isFollowing?: boolean;
isCurrentUser?: boolean;
onPressUser?: (username: string) => void;
showBio?: boolean;
@@ -51,6 +51,7 @@ export default function ConnectionListItem({
style={styles.avatarWrapper}
onPress={() => onPressUser?.(username)}
disabled={!onPressUser}
+ accessibilityLabel="connection-list-item"
>
diff --git a/src/components/profile/FollowButton.tsx b/src/components/profile/FollowButton.tsx
index f65234c29..dd627b802 100644
--- a/src/components/profile/FollowButton.tsx
+++ b/src/components/profile/FollowButton.tsx
@@ -11,8 +11,8 @@ import BlockModal from './BlockModal';
type FollowButtonProps = {
username: string;
- isFollowing?: boolean | null;
- followsYou?: boolean | null;
+ isFollowing?: boolean;
+ followsYou?: boolean;
disabled?: boolean;
size?: ComponentProps['size'];
followVariant?: ComponentProps['variant'];
@@ -22,8 +22,10 @@ type FollowButtonProps = {
style?: ComponentProps['style'];
textStyle?: ComponentProps['textStyle'];
testID?: string;
+ accessibilityLabel?: string;
isBlocked?: boolean;
isBlockedBy?: boolean;
+ onFollowChange?: (username: string, isFollowing: boolean) => void;
};
const FollowButton = ({
@@ -39,8 +41,10 @@ const FollowButton = ({
style,
textStyle,
testID,
+ accessibilityLabel,
isBlocked = false,
isBlockedBy = false,
+ onFollowChange,
}: FollowButtonProps) => {
const followMutation = useFollowMutation();
const [isFollowing, setIsFollowing] = useState(!!isFollowingProp);
@@ -71,6 +75,9 @@ const FollowButton = ({
previous,
},
{
+ onSuccess: () => {
+ onFollowChange?.(username, shouldFollow);
+ },
onError: (error) => {
setIsFollowing(previous);
Alert.alert('Unable to update follow', error?.message ?? 'Please try again.');
@@ -78,7 +85,7 @@ const FollowButton = ({
}
);
},
- [followMutation, isFollowing, username]
+ [followMutation, isFollowing, username, onFollowChange]
);
const handleBlockChange = useCallback(
@@ -116,6 +123,7 @@ const FollowButton = ({
style={style}
textStyle={textStyle}
testID={testID}
+ accessibilityLabel={accessibilityLabel}
/>
);
}
@@ -145,6 +153,7 @@ const FollowButton = ({
style={style}
textStyle={textStyle}
testID={testID}
+ accessibilityLabel={accessibilityLabel}
/>
{
return (
<>
{isMuted ? (
- handleMutePress(false)}>
+ handleMutePress(false)}
+ testID="mute-button-muted"
+ accessibilityLabel="mute-button-muted"
+ >
) : (
canMute && (
- handleMutePress(true)}>
+ handleMutePress(true)}
+ testID="mute-button-unmuted"
+ accessibilityLabel="mute-button-unmuted"
+ >
)
diff --git a/src/components/profile/ProfileHeader.tsx b/src/components/profile/ProfileHeader.tsx
index 87123811c..7ac2ce9cf 100644
--- a/src/components/profile/ProfileHeader.tsx
+++ b/src/components/profile/ProfileHeader.tsx
@@ -24,6 +24,7 @@ import { useTheme } from '@/hooks/useTheme';
import { createOrGetConversationByUsername, dmKeys } from '@/services/dm';
import { useUserStore } from '@/stores/userStore';
import { colors } from '@/utils/colorTheme';
+import { isViewingSameProfile } from '@/utils/navigation/isViewingSameProfile';
import { PROFILE, PROFILE_SETUP, ROOT } from '@/utils/navigation/routeNames';
import { AppText } from '../ui';
@@ -83,6 +84,7 @@ const ProfileHeader = ({ username, profile }: ProfileHeaderProps) => {
},
{
onError: (error) => {
+ setBlockModalVisible(false);
Alert.alert('Unable to unblock account', error.message || 'Please try again.');
},
}
@@ -131,13 +133,18 @@ const ProfileHeader = ({ username, profile }: ProfileHeaderProps) => {
const handleImagePress = (uri: string | null | undefined, isAvatar: boolean) => {
const isDefaultAvatar = avatarUri?.includes('default_avatar');
if (!uri || isDefaultAvatar) {
- navigation.push(PROFILE.EDIT, { username: username });
- return;
+ if (isProfileSetup) navigation.push(PROFILE.EDIT, { username: username });
+ else
+ rootNavigation.navigate(ROOT.PROFILE_SETUP, {
+ screen: PROFILE_SETUP.PHOTO,
+ });
}
navigation.push(PROFILE.PROFILE_PIC, { uri, username: username, isAvatar });
};
const handleMentionPress = (mentionedUsername: string) => {
+ if (isViewingSameProfile(username)) return;
+
rootNavigation.push(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username: mentionedUsername },
@@ -180,7 +187,11 @@ const ProfileHeader = ({ username, profile }: ProfileHeaderProps) => {
/>
- handleImagePress(avatarUri, true)}>
+ handleImagePress(avatarUri, true)}
+ disabled={!avatarUri && !isAuthenticatedUser}
+ >
@@ -199,6 +210,7 @@ const ProfileHeader = ({ username, profile }: ProfileHeaderProps) => {
{!isAuthenticatedUser && !isBlocking && !isBlockedBy && (
{stats.following}
@@ -33,6 +34,7 @@ const ProfileStats = ({ stats, onFollowersPress, onFollowingPress }: ProfileStat
style={styles.followRowItem}
onPress={onFollowersPress}
disabled={!onFollowersPress}
+ accessibilityLabel="profile-followers-stat"
>
{stats.followers}
diff --git a/src/components/profile/UnfollowConfirmPopover.tsx b/src/components/profile/UnfollowConfirmPopover.tsx
index d0fd15692..87494b116 100644
--- a/src/components/profile/UnfollowConfirmPopover.tsx
+++ b/src/components/profile/UnfollowConfirmPopover.tsx
@@ -80,12 +80,17 @@ const UnfollowConfirmPopover = ({
[styles.actionButton, pressed && styles.actionButtonPressed]}
onPress={handleConfirm}
disabled={disabled}
className="accessible-focus flex-row items-center gap-3"
>
-
+
{`Unfollow @${username}`}
diff --git a/src/components/search/SearchPeopleResults.tsx b/src/components/search/SearchPeopleResults.tsx
index 73dc3d3bd..a947c6cb7 100644
--- a/src/components/search/SearchPeopleResults.tsx
+++ b/src/components/search/SearchPeopleResults.tsx
@@ -14,6 +14,7 @@ import { searchKeys, searchUsers } from '@/services/search';
import { useSearchFiltersStore } from '@/stores/searchFiltersStore';
import { useUserStore } from '@/stores/userStore';
import { colors } from '@/utils/colorTheme';
+import { isViewingSameProfile } from '@/utils/navigation/isViewingSameProfile';
import { PROFILE, ROOT } from '@/utils/navigation/routeNames';
import { styles } from './SearchPeopleResults.styles';
@@ -70,11 +71,14 @@ const SearchPeopleResults = ({ query, isActive }: SearchPeopleResultsProps) => {
const users = useMemo(() => data?.pages.flatMap((page) => page.data.users) ?? [], [data]);
const goToProfile = useCallback(
- (username: string) =>
- navigation.navigate(ROOT.PROFILE, {
+ (username: string) => {
+ if (isViewingSameProfile(username)) return;
+
+ navigation.push(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username },
- }),
+ });
+ },
[navigation]
);
@@ -99,8 +103,8 @@ const SearchPeopleResults = ({ query, isActive }: SearchPeopleResultsProps) => {
username={item.username}
avatarUrl={item.avatarUrl}
bio={item.bio}
- followsYou={relationship?.follower ?? null}
- isFollowing={relationship?.following ?? null}
+ followsYou={relationship?.follower ?? undefined}
+ isFollowing={relationship?.following ?? undefined}
isBlocked={relationship?.blocking ?? false}
isBlockedBy={relationship?.blockedBy ?? false}
isCurrentUser={item.username === currentUser?.username}
@@ -113,6 +117,7 @@ const SearchPeopleResults = ({ query, isActive }: SearchPeopleResultsProps) => {
return (
item.username}
renderItem={renderItem}
diff --git a/src/components/search/SearchResultStates.tsx b/src/components/search/SearchResultStates.tsx
index b64978a9d..54dc853b1 100644
--- a/src/components/search/SearchResultStates.tsx
+++ b/src/components/search/SearchResultStates.tsx
@@ -71,6 +71,7 @@ export const EmptyState = ({ message }: EmptyStateProps) => {
{message}
diff --git a/src/components/search/SearchSuggestions.tsx b/src/components/search/SearchSuggestions.tsx
index 6a12233af..2f8a71086 100644
--- a/src/components/search/SearchSuggestions.tsx
+++ b/src/components/search/SearchSuggestions.tsx
@@ -8,14 +8,15 @@ import Avatar from '@/components/ui/Avatar';
import Spinner from '@/components/ui/Spinner';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useTheme } from '@/hooks/useTheme';
-import { searchKeys, searchTopHashtags, searchUsers } from '@/services/search';
+import { searchKeys, searchSuggestions, searchUsers } from '@/services/search';
import { SearchResultTab } from '@/types/search';
import { colors } from '@/utils/colorTheme';
-import { formatCount } from '@/utils/formatCount';
import { styles } from './SearchSuggestions.styles';
-import type { SearchHashtag, SearchUser } from '@/types/search';
+import type { SearchSuggestion, SearchSuggestionCategory, SearchUser } from '@/types/search';
+
+const toTestId = (value: string) => value.replace('#', '').replace(/ /g, '-');
export type SearchSuggestionsProps = {
query: string;
@@ -25,7 +26,7 @@ export type SearchSuggestionsProps = {
type SuggestionListItem =
| { type: 'section-divider'; id: 'divider' }
- | { type: 'hashtag'; item: SearchHashtag }
+ | { type: 'suggestion'; value: SearchSuggestion; category: SearchSuggestionCategory }
| { type: 'user'; item: SearchUser }
| { type: 'action'; action: 'search'; query: string }
| { type: 'action'; action: 'go-to'; username: string };
@@ -42,10 +43,10 @@ export const SearchSuggestions = memo(function SearchSuggestions({
const { theme } = useTheme();
const themeColors = colors[theme];
- const { data: hashtagSuggestions, isFetching: loadingHashtags } = useQuery({
- queryKey: searchKeys.hashtags(trimmedQuery),
+ const { data: textSuggestions, isFetching: loadingSuggestions } = useQuery({
+ queryKey: searchKeys.suggestions(trimmedQuery),
enabled: trimmedQuery.length > 0,
- queryFn: async () => (await searchTopHashtags(trimmedQuery)).data.hashtags,
+ queryFn: async () => (await searchSuggestions(trimmedQuery)).data,
staleTime: 60000,
});
@@ -56,18 +57,22 @@ export const SearchSuggestions = memo(function SearchSuggestions({
staleTime: 30000,
});
- const hashtagList: SearchHashtag[] = hashtagSuggestions ?? [];
+ const suggestionList: SearchSuggestion[] = textSuggestions ?? [];
const userList: SearchUser[] = userSuggestions ?? [];
const hasQuery = trimmedQuery.length > 0;
const isInitialLoading =
hasQuery &&
- hashtagList.length === 0 &&
+ suggestionList.length === 0 &&
userList.length === 0 &&
- (loadingHashtags || loadingUsers);
+ (loadingSuggestions || loadingUsers);
if (!trimmedQuery.length)
return (
-
+
Search for people, topics, or hashtags to get started.
@@ -83,10 +88,22 @@ export const SearchSuggestions = memo(function SearchSuggestions({
const suggestionItems: SuggestionListItem[] = [];
- if (hashtagList.length)
- suggestionItems.push(...hashtagList.map((item) => ({ type: 'hashtag' as const, item })));
+ if (suggestionList.length)
+ suggestionItems.push(
+ ...suggestionList.map((value) => {
+ const category: SearchSuggestionCategory = value.trim().startsWith('#')
+ ? 'hashtag'
+ : 'topic';
+
+ return {
+ type: 'suggestion' as const,
+ value,
+ category,
+ };
+ })
+ );
- if (hashtagList.length && userList.length)
+ if (suggestionList.length && userList.length)
suggestionItems.push({ type: 'section-divider', id: 'divider' });
if (userList.length)
@@ -111,8 +128,8 @@ export const SearchSuggestions = memo(function SearchSuggestions({
switch (item.type) {
case 'section-divider':
return `divider-${item.id}-${index}`;
- case 'hashtag':
- return `hash-${item.item.hashtag}`;
+ case 'suggestion':
+ return `suggestion-${item.value}`;
case 'user':
return `user-${item.item.username}-${index}`;
case 'action':
@@ -131,28 +148,32 @@ export const SearchSuggestions = memo(function SearchSuggestions({
);
- case 'hashtag':
+ case 'suggestion':
return (
onSuggestionPress(`#${item.item.hashtag}`, 'top')}
+ onPress={() => onSuggestionPress(item.value, 'top')}
+ accessibilityLabel={`suggestion-${toTestId(item.value)}`}
+ testID={`suggestion-${toTestId(item.value)}`}
>
- #{item.item.hashtag}
-
-
- {formatCount(item.item.usageCount)} tweets
+ {item.value}
);
case 'user':
return (
- onGoToProfile(item.item.username)}>
+ onGoToProfile(item.item.username)}
+ accessibilityLabel={`suggestion-user-${item.item.username}`}
+ testID={`suggestion-user-${item.item.username}`}
+ >
onSuggestionPress(item.query, 'top')}
+ accessibilityLabel="suggestion-search-action"
+ testID="suggestion-search-action"
>
{`Search for "${item.query}"`}
@@ -186,7 +209,12 @@ export const SearchSuggestions = memo(function SearchSuggestions({
);
}
return (
- onGoToProfile(item.username)}>
+ onGoToProfile(item.username)}
+ accessibilityLabel="suggestion-goto-action"
+ testID="suggestion-goto-action"
+ >
{`Go to @${item.username}`}
@@ -197,8 +225,8 @@ export const SearchSuggestions = memo(function SearchSuggestions({
}
}}
ItemSeparatorComponent={({ first, second }) =>
- (first?.type === 'hashtag' || first?.type === 'user' || first?.type === 'action') &&
- (second?.type === 'hashtag' || second?.type === 'user' || second?.type === 'action') ? (
+ (first?.type === 'suggestion' || first?.type === 'user' || first?.type === 'action') &&
+ (second?.type === 'suggestion' || second?.type === 'user' || second?.type === 'action') ? (
) : null
}
diff --git a/src/components/search/SearchTopPeople.tsx b/src/components/search/SearchTopPeople.tsx
index 07cb62c98..585d18f6f 100644
--- a/src/components/search/SearchTopPeople.tsx
+++ b/src/components/search/SearchTopPeople.tsx
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react';
import { FlatList, Pressable, Text, View, useWindowDimensions } from 'react-native';
import { NavigationProp, useNavigation } from '@react-navigation/native';
+import { StackNavigationProp } from '@react-navigation/stack';
import { Image } from 'expo-image';
import FollowButton from '@/components/profile/FollowButton';
@@ -10,6 +11,7 @@ import Spinner from '@/components/ui/Spinner';
import { useTheme } from '@/hooks/useTheme';
import { useUserStore } from '@/stores/userStore';
import { colors } from '@/utils/colorTheme';
+import { isViewingSameProfile } from '@/utils/navigation/isViewingSameProfile';
import { PROFILE, ROOT, SEARCH_TABS } from '@/utils/navigation/routeNames';
import { styles } from './SearchTopPeople.styles';
@@ -27,6 +29,7 @@ const SearchTopPeople = ({ users, isLoading, query }: SearchTopPeopleProps) => {
const { theme } = useTheme();
const themeColors = colors[theme];
const navigation = useNavigation>();
+ const rootNavigation = useNavigation>();
const { width } = useWindowDimensions();
const currentUser = useUserStore((state) => state.user);
@@ -39,13 +42,14 @@ const SearchTopPeople = ({ users, isLoading, query }: SearchTopPeopleProps) => {
const goToProfile = useCallback(
(username: string) => {
- if (!username) return;
- navigation.navigate(ROOT.PROFILE, {
+ if (isViewingSameProfile(username)) return;
+
+ rootNavigation.push(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username },
});
},
- [navigation]
+ [rootNavigation]
);
const renderItem = useCallback(
@@ -106,8 +110,8 @@ const SearchTopPeople = ({ users, isLoading, query }: SearchTopPeopleProps) => {
{
return (
-
+
People
-
+
View all
diff --git a/src/components/search/SearchTweetResults.tsx b/src/components/search/SearchTweetResults.tsx
index 2ca9b8c1f..2a99846e0 100644
--- a/src/components/search/SearchTweetResults.tsx
+++ b/src/components/search/SearchTweetResults.tsx
@@ -1,10 +1,16 @@
import { useCallback, useMemo, useRef } from 'react';
import { useFocusEffect } from '@react-navigation/native';
-import { useInfiniteQuery, useQuery, type InfiniteData } from '@tanstack/react-query';
+import {
+ useInfiniteQuery,
+ useQuery,
+ useQueryClient,
+ type InfiniteData,
+} from '@tanstack/react-query';
import SearchTopPeople from '@/components/search/SearchTopPeople';
import TimelineFeedList from '@/components/ui/TimelineFeedList';
+import { getTweetCache } from '@/libs/tweetCache';
import { searchKeys, searchTweets, searchUsers, type SearchUsersResult } from '@/services/search';
import { useSearchFiltersStore } from '@/stores/searchFiltersStore';
@@ -21,6 +27,8 @@ type SearchTweetResultsProps = {
};
const SearchTweetResults = ({ query, tab, isActive }: SearchTweetResultsProps) => {
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
const peopleFilter = useSearchFiltersStore((state) => state.peopleFilter);
const excludeMutedAndBlocked = useSearchFiltersStore((state) => state.excludeMutedAndBlocked);
const latestFilters = useRef({ peopleFilter, excludeMutedAndBlocked });
@@ -37,15 +45,23 @@ const SearchTweetResults = ({ query, tab, isActive }: SearchTweetResultsProps) =
enabled: query.length > 0 && isActive,
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.pagination?.nextCursor ?? null,
- queryFn: async ({ pageParam }) =>
- searchTweets({
+ queryFn: async ({ pageParam }) => {
+ const result = await searchTweets({
query,
tab,
cursor: pageParam ?? undefined,
limit: 10,
peopleFilter,
excludeMutedAndBlocked,
- }),
+ });
+
+ // Populate the tweet cache with search results
+ if (result.data && Array.isArray(result.data)) {
+ tweetCache.setTweets(result.data);
+ }
+
+ return result;
+ },
});
const topPeopleQuery = useQuery({
diff --git a/src/components/settings/SettingsSections.tsx b/src/components/settings/SettingsSections.tsx
index aa7c963fa..8d65b38a7 100644
--- a/src/components/settings/SettingsSections.tsx
+++ b/src/components/settings/SettingsSections.tsx
@@ -24,26 +24,34 @@ export function PrivacySettingsCard({ onPress }: { onPress?: () => void }) {
);
}
-export function NotificationsSettingsCard({ onPress }: { onPress?: () => void }) {
+export function AppearanceSettingsCard({ onPress }: { onPress?: () => void }) {
return (
);
}
-export function AppearanceSettingsCard({ onPress }: { onPress?: () => void }) {
+export function TermsOfServiceSettingsCard({ onPress }: { onPress?: () => void }) {
return (
+ );
+}
+
+export function PrivacyPolicySettingsCard({ onPress }: { onPress?: () => void }) {
+ return (
+
);
diff --git a/src/components/sidebar/SidebarAccountSection.tsx b/src/components/sidebar/SidebarAccountSection.tsx
index 052565ca6..d3a30ebcd 100644
--- a/src/components/sidebar/SidebarAccountSection.tsx
+++ b/src/components/sidebar/SidebarAccountSection.tsx
@@ -116,8 +116,12 @@ const SidebarAccountSection = memo(
style={styles.statItem}
onPress={onFollowingPress}
disabled={!onFollowingPress}
+ accessibilityLabel="sidebar-following-button"
>
-
+
{formatCount(userStats.followingCount)}
@@ -128,8 +132,12 @@ const SidebarAccountSection = memo(
style={styles.statItem}
onPress={onFollowersPress}
disabled={!onFollowersPress}
+ accessibilityLabel="sidebar-followers-button"
>
-
+
{formatCount(userStats.followersCount)}
diff --git a/src/components/ui/ActorAvatarGroup.tsx b/src/components/ui/ActorAvatarGroup.tsx
index 63c335516..ada9d616b 100644
--- a/src/components/ui/ActorAvatarGroup.tsx
+++ b/src/components/ui/ActorAvatarGroup.tsx
@@ -13,7 +13,7 @@ export default function ActorAvatarGroup({
actors,
size = 32,
}: {
- actors: { avatarUrl: string; username: string; displayName: string; isFollowing: boolean }[];
+ actors: { avatarUrl: string; username: string; displayName: string }[];
size?: number;
}) {
const maxActors = actors.slice(0, 3);
diff --git a/src/components/ui/AppText.tsx b/src/components/ui/AppText.tsx
index 31c4821fd..bf68c4938 100644
--- a/src/components/ui/AppText.tsx
+++ b/src/components/ui/AppText.tsx
@@ -39,7 +39,7 @@ const variantStyles: Record = {
timestamp: 'text-[15px] text-[var(--color-muted-foreground)] font-[Inter_400Regular]',
username: 'text-[15px] text-[var(--color-muted-foreground)] font-[Inter_400Regular]',
description: 'text-[15px] text-[var(--color-muted-foreground)] font-[Inter_400Regular]',
- trend: 'text-[14px] font-bold text-[var(--color-muted-foreground)] font-[Inter_600SemiBold]',
+ trend: 'text-[14px] font-bold text-[var(--color-muted-foreground)] font-[Inter_700Bold]',
};
export default function AppText({
diff --git a/src/components/ui/Avatar.tsx b/src/components/ui/Avatar.tsx
index 0b66f23b0..405e3f9ff 100644
--- a/src/components/ui/Avatar.tsx
+++ b/src/components/ui/Avatar.tsx
@@ -120,7 +120,7 @@ const Avatar = ({
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
- alignItems: 'center',
+ alignItems: 'flex-start',
gap: 8,
justifyContent: 'space-between',
},
diff --git a/src/components/ui/ConfirmationModal.tsx b/src/components/ui/ConfirmationModal.tsx
index fec7d727c..eb850916e 100644
--- a/src/components/ui/ConfirmationModal.tsx
+++ b/src/components/ui/ConfirmationModal.tsx
@@ -3,9 +3,10 @@ import { Platform, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '@/hooks/useTheme';
import { colors } from '@/utils';
-import { AppText, ModalOverlay } from '../ui';
-import Button, { ButtonVariant } from '../ui/Button';
-import ConfirmationDialog from '../ui/ConfirmationDialog';
+import AppText from './AppText';
+import Button, { ButtonVariant } from './Button';
+import ConfirmationDialog from './ConfirmationDialog';
+import ModalOverlay from './ModalOverlay';
type ConfirmationModalProps = {
modalVisible: boolean;
@@ -59,6 +60,7 @@ const ConfirmationModal = ({
onPress={handleConfirm}
style={styles.warningModalButton}
testID="block-button"
+ accessibilityLabel="block-button"
/>
diff --git a/src/components/ui/HighlightableTextInput.tsx b/src/components/ui/HighlightableTextInput.tsx
index 21593e8f1..1255c7ba2 100644
--- a/src/components/ui/HighlightableTextInput.tsx
+++ b/src/components/ui/HighlightableTextInput.tsx
@@ -1,4 +1,4 @@
-import React, { useRef } from 'react';
+import React, { useMemo, useRef } from 'react';
import { TextInput as RNTextInput, StyleProp, TextInputProps, TextStyle, View } from 'react-native';
@@ -36,25 +36,34 @@ const HighlightableTextInput: React.FC = ({
const highlightColor = currentColors.primary;
const textInputRef = useRef(null);
- const highlightStyle = { fontWeight: 'bold' as const, color: highlightColor };
+ const highlightStyle = useMemo(
+ () => ({ fontWeight: 'bold' as const, color: highlightColor }),
+ [highlightColor]
+ );
- const triggersConfig = {
- mention: {
- trigger: '@',
- textStyle: highlightStyle,
- },
- };
+ const triggersConfig = useMemo(
+ () => ({
+ mention: {
+ trigger: '@',
+ textStyle: highlightStyle,
+ },
+ }),
+ [highlightStyle]
+ );
- const patternsConfig = {
- mention: {
- pattern: /(@\w+)/,
- textStyle: highlightStyle,
- },
- hashtag: {
- pattern: /(#\w+)/,
- textStyle: highlightStyle,
- },
- };
+ const patternsConfig = useMemo(
+ () => ({
+ mention: {
+ pattern: /(@\w+)/,
+ textStyle: highlightStyle,
+ },
+ hashtag: {
+ pattern: /(#\w+)/,
+ textStyle: highlightStyle,
+ },
+ }),
+ [highlightStyle]
+ );
const { textInputProps, triggers } = useMentions({
value,
@@ -72,6 +81,7 @@ const HighlightableTextInput: React.FC = ({
placeholderTextColor={placeholderTextColor}
style={style}
multiline={multiline}
+ scrollEnabled={false}
textAlignVertical={textAlignVertical}
testID={testID}
autoCorrect={false}
diff --git a/src/components/ui/MediaPicker.tsx b/src/components/ui/MediaPicker.tsx
index 9b988cc08..60e00e5cd 100644
--- a/src/components/ui/MediaPicker.tsx
+++ b/src/components/ui/MediaPicker.tsx
@@ -31,6 +31,7 @@ export default function MediaPicker({
onPress={() => onSelectAsset(asset)}
style={styles.imageTouchable}
testID={`media-image-${asset.id}`}
+ accessibilityLabel="composer-media-image"
>
diff --git a/src/components/ui/NewTweetsIndicator.tsx b/src/components/ui/NewTweetsIndicator.tsx
new file mode 100644
index 000000000..329c253b4
--- /dev/null
+++ b/src/components/ui/NewTweetsIndicator.tsx
@@ -0,0 +1,97 @@
+import { Pressable, StyleSheet, View } from 'react-native';
+
+import Ionicons from '@expo/vector-icons/Ionicons';
+import { Image } from 'expo-image';
+import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
+
+import { useTheme } from '@/hooks/useTheme';
+import { colors } from '@/utils/colorTheme';
+
+interface NewTweetsIndicatorProps {
+ authorAvatars: string[];
+ onPress: () => void;
+ testID?: string;
+}
+
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
+
+const NewTweetsIndicator = ({ authorAvatars, onPress, testID }: NewTweetsIndicatorProps) => {
+ const { theme } = useTheme();
+ const currentColors = colors[theme];
+
+ const avatarsWidth = authorAvatars.length > 0 ? 28 + (authorAvatars.length - 1) * 18 : 0;
+
+ return (
+
+
+
+
+ {authorAvatars.length > 0 && (
+
+ {authorAvatars.map((avatarUrl, index) => (
+
+
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'absolute',
+ top: 12,
+ alignSelf: 'center',
+ zIndex: 100,
+ },
+ shadow: {
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.25,
+ shadowRadius: 4,
+ elevation: 5,
+ },
+ pill: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 5,
+ paddingHorizontal: 14,
+ borderRadius: 24,
+ gap: 8,
+ },
+ avatarsContainer: {
+ flexDirection: 'row',
+ height: 28,
+ },
+ avatarWrapper: {
+ position: 'absolute',
+ borderWidth: 2,
+ overflow: 'hidden',
+ },
+ avatarImage: {
+ width: 25,
+ height: 25,
+ },
+});
+
+export default NewTweetsIndicator;
diff --git a/src/components/ui/RadioButton.tsx b/src/components/ui/RadioButton.tsx
index f000392ea..52b67bba9 100644
--- a/src/components/ui/RadioButton.tsx
+++ b/src/components/ui/RadioButton.tsx
@@ -6,22 +6,33 @@ export const Radio: React.FC<{
label: string;
selected: boolean;
onPress: () => void;
-}> = ({ label, selected, onPress }) => (
-
-
- {label}
- = ({ label, selected, onPress }) => {
+ const labelId = label.toLowerCase().split(' ').join('-');
+ return (
+
+
- {selected && }
-
-
-
-);
+ {label}
+
+ {selected && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/ui/ReplyInput.tsx b/src/components/ui/ReplyInput.tsx
index c307bf781..62a6a1409 100644
--- a/src/components/ui/ReplyInput.tsx
+++ b/src/components/ui/ReplyInput.tsx
@@ -98,6 +98,7 @@ export default function ReplyInput({
ref={textInputRef}
{...textInputProps}
testID="reply-input"
+ accessibilityLabel="reply-input"
style={styles.replyInput}
placeholder="Post your reply"
placeholderTextColor={colors[theme].mutedForeground}
@@ -136,6 +137,7 @@ export default function ReplyInput({
diff --git a/src/components/ui/SelectedMediaRow.tsx b/src/components/ui/SelectedMediaRow.tsx
index c95abbc3a..215f48141 100644
--- a/src/components/ui/SelectedMediaRow.tsx
+++ b/src/components/ui/SelectedMediaRow.tsx
@@ -60,6 +60,7 @@ export default function SelectedMediaRow({
onPress={() => onRemove(asset)}
style={styles.removeButton}
testID={`remove-media-${asset.id}`}
+ accessibilityLabel="composer-remove-media"
>
diff --git a/src/components/ui/Suggestions.tsx b/src/components/ui/Suggestions.tsx
index 8abf9a454..4d88b7b21 100644
--- a/src/components/ui/Suggestions.tsx
+++ b/src/components/ui/Suggestions.tsx
@@ -11,7 +11,7 @@ import Avatar from './Avatar';
type UserSuggestion = {
username: string;
displayName: string;
- avatarUrl: string;
+ avatarUrl: string | null;
};
type SuggestionsProps = {
@@ -71,7 +71,7 @@ const Suggestions: React.FC = ({
diff --git a/src/components/ui/TimelineFeedList.tsx b/src/components/ui/TimelineFeedList.tsx
index c51c74f33..66df0024b 100644
--- a/src/components/ui/TimelineFeedList.tsx
+++ b/src/components/ui/TimelineFeedList.tsx
@@ -1,8 +1,17 @@
-import { memo, useCallback, useMemo, useRef, useState, type ReactElement } from 'react';
+import {
+ memo,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+ type ComponentRef,
+ type ReactElement,
+ type RefObject,
+} from 'react';
import { RefreshControl, Text, View } from 'react-native';
-import { useNavigation } from '@react-navigation/native';
+import { useNavigation, useScrollToTop } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
@@ -14,6 +23,7 @@ import { useDrawerSwipe } from '@/hooks/navigation/useDrawerSwipe';
import { useTheme } from '@/hooks/useTheme';
import { useUserStore } from '@/stores/userStore';
import { colors as themeColors } from '@/utils/colorTheme';
+import { isViewingSameProfile } from '@/utils/navigation/isViewingSameProfile';
import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames';
import type { TimelineFeedQueryResult } from '@/hooks/useFeed';
@@ -32,6 +42,8 @@ interface TimelineFeedListProps {
timelineUser?: UserMetadata;
blockedBy?: boolean;
allowQuoting?: boolean;
+ listRef?: RefObject> | null>;
+ username?: string;
}
export const MemoizedTweetItem = memo<{
@@ -99,7 +111,13 @@ const TimelineFeedList = ({
timelineUser,
blockedBy,
allowQuoting = true,
+ listRef,
+ username,
}: TimelineFeedListProps) => {
+ const internalRef = useRef>>(null);
+ const flashListRef = listRef ?? internalRef;
+ useScrollToTop(flashListRef);
+
const [refreshing, setRefreshing] = useState(false);
const { onTouchStart, onTouchEnd } = useDrawerSwipe();
const { theme } = useTheme();
@@ -113,7 +131,6 @@ const TimelineFeedList = ({
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading, error } =
feedResult;
const tweets = useMemo(() => data?.pages.flatMap((page) => page.data) ?? [], [data]);
- const username = tweets[0]?.author.username ?? null;
const tweetsWithReposter = useMemo(() => {
if (!timelineUser || timelineUser.username === authUsername) return tweets;
@@ -126,6 +143,7 @@ const TimelineFeedList = ({
username: timelineUser.username,
displayName: timelineUser.displayName,
avatarUrl: timelineUser.avatarUrl,
+ relationship: timelineUser.relationship,
},
}
: tweet
@@ -149,11 +167,14 @@ const TimelineFeedList = ({
}, [quoteTweet]);
const goToProfile = useCallback(
- (username: string) =>
- rootNavigation.navigate(ROOT.PROFILE, {
+ (username: string) => {
+ if (isViewingSameProfile(username)) return;
+
+ rootNavigation.push(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username },
- }),
+ });
+ },
[rootNavigation]
);
@@ -184,7 +205,7 @@ const TimelineFeedList = ({
rootNavigation.navigate(ROOT.TWEET, {
screen: TWEET.DETAIL,
- params: { tweetId: trimmedId },
+ params: { tweetId: trimmedId, initialOpenComposer: true },
});
},
[rootNavigation]
@@ -223,9 +244,10 @@ const TimelineFeedList = ({
}, [refetch]);
const renderItem: ListRenderItem = useCallback(
- ({ item }) => (
+ ({ item, index }) => (
item.id, []);
+ const keyExtractor = useCallback(
+ (item: UITweet) =>
+ item.repostedBy ? `${item.id}-repost-${item.repostedBy.username}` : item.id,
+ []
+ );
- const ListFooter = useCallback(
- () => (
-
- {isFetchingNextPage || isLoading ? (
+ const ListFooter = useCallback(() => {
+ if (isFetchingNextPage) {
+ return (
+
- ) : !hasNextPage && !isLoading && !error && showEndMessage ? (
+
+ );
+ }
+
+ if (!hasNextPage && !isLoading && !error && showEndMessage) {
+ return (
+
You're all caught up
- ) : null}
-
- ),
- [isFetchingNextPage, isLoading, hasNextPage, error, showEndMessage]
- );
+
+ );
+ }
+
+ return null;
+ }, [isFetchingNextPage, isLoading, hasNextPage, error, showEndMessage]);
const ListEmptyComponent = useCallback(
() => (
- {isLoading ? (
+ {isLoading && !refreshing ? (
) : error ? (
@@ -300,7 +333,7 @@ const TimelineFeedList = ({
) : null}
),
- [isLoading, error, emptyMessage, emptySubMessage]
+ [isLoading, refreshing, error, emptyMessage, emptySubMessage]
);
const blockedTab = !!blockedBy ? (
@@ -329,20 +362,23 @@ const TimelineFeedList = ({
return (
@@ -24,6 +25,8 @@ export const ToggleSwitch: React.FC<{
ios_backgroundColor={Colors.trackOff}
value={value}
onValueChange={onChange}
+ accessibilityLabel={`toggle-${labelId}`}
+ testID={`toggle-${labelId}`}
/>
);
diff --git a/src/components/ui/TrendCard.tsx b/src/components/ui/TrendCard.tsx
index 211b804df..7b5da7e5f 100644
--- a/src/components/ui/TrendCard.tsx
+++ b/src/components/ui/TrendCard.tsx
@@ -23,36 +23,47 @@ const formatTweetCount = (count: number) => {
};
const TrendCard = ({ rank, trend, showRank }: TrendCardProps) => {
- const label = trend.category
- ? `Trending in ${trend.category.charAt(0).toUpperCase() + trend.category.slice(1)}`
- : 'Trending';
+ const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
+
+ const isKeyword = !trend.hashtag.startsWith('#');
+
+ const label = isKeyword
+ ? `${capitalize(trend.category)} · Trending`
+ : trend.category
+ ? `Trending in ${capitalize(trend.category)}`
+ : 'Trending';
+
const trendTitle = showRank ? `${rank} · ${label}` : label;
- const hashtag = trend.hashtag[0] === '#' ? trend.hashtag : `#${trend.hashtag}`;
- const count = formatTweetCount(trend.tweetCount);
+ const hashtag = trend.hashtag;
+ const count = formatTweetCount(trend.tweetsCount);
const handleHashtagPress = useCallback((hashtag: string) => {
if (!hashtag) return;
- const normalizedQuery = hashtag.startsWith('#') ? hashtag : `#${hashtag}`;
-
push(ROOT.SEARCH, {
- initialQuery: normalizedQuery,
+ initialQuery: hashtag,
initialTab: 'top',
});
}, []);
return (
- handleHashtagPress(trend.hashtag)}>
+ handleHashtagPress(trend.hashtag)}
+ accessibilityLabel={`trend-card-${trend.hashtag.replace('#', '')}`}
+ testID={`trend-card-${trend.hashtag.replace('#', '')}`}
+ >
{trendTitle}
- {hashtag}
+ {hashtag}
-
- {count} posts
+
+
+ {count} {trend.tweetsCount > 1 ? 'posts' : 'post'}
+
diff --git a/src/components/ui/TrendsList.tsx b/src/components/ui/TrendsList.tsx
index 72fd10b3f..6b072331e 100644
--- a/src/components/ui/TrendsList.tsx
+++ b/src/components/ui/TrendsList.tsx
@@ -1,6 +1,6 @@
import { memo } from 'react';
-import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { UseQueryResult } from '@tanstack/react-query';
@@ -21,6 +21,8 @@ type TrendsListProps = {
scrollEnabled?: boolean;
showFooter?: boolean;
testID?: string;
+ accessibilityLabel?: string;
+ allowLoader?: boolean;
};
const TrendsList = ({
@@ -32,24 +34,63 @@ const TrendsList = ({
scrollEnabled = true,
showFooter = false,
testID,
+ accessibilityLabel,
+ allowLoader = true,
}: TrendsListProps) => {
const { theme } = useTheme();
const styles = getStyles(theme);
+ const currentColors = colors[theme];
const { data, isLoading, isRefetching, error } = trendsResult;
const trends = showAll ? data?.data : data?.data.slice(0, 6);
+ const handleRefresh = () => {
+ if (!isRefetching) {
+ trendsResult.refetch();
+ }
+ };
+
+ const refreshControl = (
+
+ );
+
const ListEmptyComponent = isLoading ? (
-
+
) : error ? (
-
-
- {errorMessage}
-
-
+ allowLoader ? (
+
+
+ {errorMessage}
+
+
+ ) : (
+
+
+ {errorMessage}
+
+
+ )
+ ) : allowLoader ? (
+
+ {emptyMessage}
+
) : (
{emptyMessage}
@@ -59,14 +100,10 @@ const TrendsList = ({
const ItemSeparator = memo(() => );
ItemSeparator.displayName = 'ItemSeparator';
- if (isLoading || isRefetching) {
- return null;
- }
-
return (
-
+
{trends?.length ? (
-
+
{trends.map((item, index) => (
@@ -91,11 +128,17 @@ function getStyles(theme: 'light' | 'dark') {
alignItems: 'center',
flex: 1,
backgroundColor: colors[theme].background,
+ flexGrow: 1,
},
container: {
flex: 1,
backgroundColor: colors[theme].background,
paddingTop: 16,
},
+ emptyContainer: {
+ justifyContent: 'flex-start',
+ alignItems: 'center',
+ flex: 1,
+ },
});
}
diff --git a/src/components/ui/Tweet.tsx b/src/components/ui/Tweet.tsx
index 07310cc58..9d54971ae 100644
--- a/src/components/ui/Tweet.tsx
+++ b/src/components/ui/Tweet.tsx
@@ -1,11 +1,10 @@
/* eslint-disable react-native/no-raw-text */
/* eslint-disable react-native/no-inline-styles */
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
- Image,
Modal,
Pressable,
Share,
@@ -16,23 +15,21 @@ import {
import { MaterialCommunityIcons } from '@expo/vector-icons';
import Ionicons from '@expo/vector-icons/Ionicons';
+import { useQuery } from '@tanstack/react-query';
+import { Image } from 'expo-image';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { TweetActionBar } from '@/components/notifications/types/TweetActionBar';
import FollowButton from '@/components/profile/FollowButton';
+import { useRetweetTweetMutation } from '@/hooks/tweets/useTweetInteractions';
import { useTheme } from '@/hooks/useTheme';
import { queryClient } from '@/libs/queryClient';
-import { push } from '@/navigation/navigationRef';
-import {
- deleteTweet,
- getTweetSummary,
- likeTweet,
- retweetTweet,
- unlikeTweet,
- unretweetTweet,
-} from '@/services/tweets';
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
+import { goBack, push } from '@/navigation/navigationRef';
+import { deleteTweet, getTweetSummary } from '@/services/tweets';
import { useUserStore } from '@/stores/userStore';
import { colors } from '@/utils/colorTheme';
-import { formatCount } from '@/utils/formatCount';
import { formatTimeToHR } from '@/utils/formatTime';
import { ROOT } from '@/utils/navigation/routeNames';
import { buildTweetUrl } from '@/utils/url';
@@ -84,12 +81,31 @@ export default function Tweet({
const { theme } = useTheme();
const palette = colors[theme];
const viewerUsername = useUserStore((state) => state.user.username);
- const [isLiked, setIsLiked] = useState(tweet.isLiked);
- const [isRetweeted, setIsRetweeted] = useState(tweet.isRetweeted);
- const [likeCount, setLikeCount] = useState(tweet.likeCount);
- const [retweetCount, setRetweetCount] = useState(tweet.retweetCount);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [viewerIndex, setViewerIndex] = useState(0);
+
+ const tweetCache = getTweetCache(queryClient);
+
+ // Initialize cache ONCE using layout effect for synchronous execution before paint
+ // This ensures TweetActionBar reads the correct initial state
+ useLayoutEffect(() => {
+ if (!tweetCache.hasTweet(tweet.id)) {
+ tweetCache.setTweet(tweet);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [tweet.id]); // Only run when tweet ID changes, not when tweet object changes
+
+ const { data: cachedTweet } = useQuery({
+ queryKey: queryKeys.tweet(tweet.id),
+ queryFn: () => tweetCache.getTweet(tweet.id),
+ staleTime: Infinity,
+ gcTime: Infinity,
+ initialData: tweet,
+ });
+
+ const isRetweeted = cachedTweet?.isRetweeted ?? tweet.isRetweeted;
+
+ const retweetMutation = useRetweetTweetMutation();
const [viewerMediaSource, setViewerMediaSource] = useState<'main' | 'quoted'>('main');
const [showRetweetMenu, setShowRetweetMenu] = useState(false);
const [showTweetMenu, setShowTweetMenu] = useState(false);
@@ -109,13 +125,6 @@ export default function Tweet({
? viewerMedia.width / viewerMedia.height
: undefined;
- useEffect(() => {
- setIsLiked(tweet.isLiked);
- setIsRetweeted(tweet.isRetweeted);
- setLikeCount(tweet.likeCount);
- setRetweetCount(tweet.retweetCount);
- }, [tweet.id, tweet.isLiked, tweet.isRetweeted, tweet.likeCount, tweet.retweetCount]);
-
const createdAt = formatTimeToHR(tweet.createdAt);
const repostLabel = useMemo(() => {
@@ -125,51 +134,34 @@ export default function Tweet({
const who =
reposter.displayName?.trim() || (reposter.username ? `@${reposter.username}` : null);
+ if (reposter.username === viewerUsername) return 'You Reposted';
+
return who ? `${who} reposted` : 'Reposted';
}
if (tweet.isRepost) return 'You Reposted';
return null;
- }, [tweet.isRepost, tweet.repostedBy]);
-
- const toggleLike = async () => {
- setIsLiked((prev) => !prev);
- setLikeCount((c) => (isLiked ? Math.max(0, c - 1) : c + 1));
+ }, [tweet.isRepost, tweet.repostedBy, viewerUsername]);
- try {
- if (!isLiked) await likeTweet(tweet.id);
- else await unlikeTweet(tweet.id);
- queryClient.invalidateQueries({ queryKey: ['tweetLikers', tweet.id] });
- } catch {
- setIsLiked((prev) => !prev);
- setLikeCount((c) => (isLiked ? c + 1 : Math.max(0, c - 1)));
- }
- };
-
- function handleRetweetPress() {
- setShowRetweetMenu(true);
- }
-
- async function handleRepost() {
- setIsRetweeted((prev) => !prev);
- setRetweetCount((c) => (isRetweeted ? Math.max(0, c - 1) : c + 1));
- try {
- if (!isRetweeted) await retweetTweet(tweet.id);
- else await unretweetTweet(tweet.id);
- queryClient.invalidateQueries({ queryKey: ['tweetRetweeters', tweet.id] });
- } catch {
- setIsRetweeted((prev) => !prev);
- setRetweetCount((c) => (isRetweeted ? c + 1 : Math.max(0, c - 1)));
- }
+ function handleRepost() {
+ retweetMutation.mutate({
+ tweetId: tweet.id,
+ retweet: !isRetweeted,
+ });
}
function handleQuote() {
setShowRetweetMenu(false);
// Pass the success handler to the parent
onQuote?.(tweet, () => {
- // Increment the retweet count locally when quote is successful
- setRetweetCount((c) => c + 1);
+ // Increment the retweet count in cache when quote is successful
+ const currentTweet = tweetCache.getTweet(tweet.id);
+ if (currentTweet) {
+ tweetCache.updateTweet(tweet.id, {
+ retweetCount: currentTweet.retweetCount + 1,
+ });
+ }
});
}
@@ -233,11 +225,13 @@ export default function Tweet({
}, []);
const handleDeleteTweet = async () => {
+ setShowTweetMenu(false);
try {
await deleteTweet(tweet.id);
setIsDeleted(true);
- setShowTweetMenu(false);
queryClient.invalidateQueries({ queryKey: ['tweet', tweet.id] });
+ queryClient.invalidateQueries({ queryKey: ['following-feed'] });
+ if (detailed) goBack();
} catch (error) {
const message = error instanceof Error ? error.message : 'Please try again.';
Alert.alert('Unable to delete tweet', message);
@@ -249,9 +243,10 @@ export default function Tweet({
return (
{repostLabel ? (
@@ -294,22 +289,30 @@ export default function Tweet({
+ {tweet.content && tweet.content.trim() !== '' && (
+
+ {loadingSummary ? (
+
+ ) : (
+
+ )}
+
+ )}
- {loadingSummary ? (
-
- ) : (
-
- )}
-
-
@@ -326,6 +329,7 @@ export default function Tweet({
{tweet.media && tweet.media.length > 0 ? (
{
onPressMedia?.(i);
@@ -337,7 +341,6 @@ export default function Tweet({
{tweet.quotedTweet ? (
'isDeleted' in tweet.quotedTweet ? (
- {/* eslint-disable-next-line react-native/no-raw-text */}
This tweet was deleted.
) : (
@@ -381,7 +384,6 @@ export default function Tweet({
{tweet.quotedTweet.author.displayName}
- {/* eslint-disable-next-line react-native/no-raw-text */}
@{tweet.quotedTweet.author.username}
- {/* eslint-disable-next-line react-native/no-raw-text */}
· {formatTimeToHR(tweet.quotedTweet.createdAt)}
@@ -411,6 +412,7 @@ export default function Tweet({
className="mt-2"
>
{
onPressMedia?.(i);
@@ -487,6 +489,7 @@ export default function Tweet({
{showActions ? (
-
- onPressReply?.(tweet)}
- testID="reply-button"
- className="flex-row items-center gap-1"
- >
-
-
- {formatCount(tweet.replyCount)}
-
-
-
- onLongPressRetweet?.(tweet.id)}
- testID="retweet-button"
- className="flex-row items-center gap-1"
- >
-
-
- {formatCount(retweetCount)}
-
-
-
- onLongPressLike?.(tweet.id)}
- testID="like-button"
- className="flex-row items-center gap-1"
- >
-
-
- {formatCount(likeCount)}
-
-
-
-
-
-
-
+ onPressReply?.(tweet)}
+ onRetweetPress={() => setShowRetweetMenu(true)}
+ onLongPressLike={onLongPressLike}
+ onLongPressRetweet={onLongPressRetweet}
+ onSharePress={onShareTweet}
+ showShare
+ testIdPrefix=""
+ />
) : null}
>
@@ -618,22 +569,29 @@ export default function Tweet({
+ {tweet.content && tweet.content.trim() !== '' && (
+
+ {loadingSummary ? (
+
+ ) : (
+
+ )}
+
+ )}
- {loadingSummary ? (
-
- ) : (
-
- )}
-
-
@@ -660,6 +618,7 @@ export default function Tweet({
{tweet.media && tweet.media.length > 0 ? (
{
onPressMedia?.(i);
@@ -671,7 +630,6 @@ export default function Tweet({
{tweet.quotedTweet ? (
'isDeleted' in tweet.quotedTweet ? (
- {/* eslint-disable-next-line react-native/no-raw-text */}
This tweet was deleted.
) : (
@@ -718,7 +676,6 @@ export default function Tweet({
{tweet.quotedTweet.author.displayName}
- {/* eslint-disable-next-line react-native/no-raw-text */}
@{tweet.quotedTweet.author.username}
- {/* eslint-disable-next-line react-native/no-raw-text */}
· {formatTimeToHR(tweet.quotedTweet.createdAt)}
@@ -747,6 +703,7 @@ export default function Tweet({
className="mt-2"
>
{
onPressMedia?.(i);
@@ -836,68 +793,21 @@ export default function Tweet({
)}
{showActions ? (
-
- onPressReply?.(tweet)}
- testID="reply-button"
- className="flex-row items-center gap-1"
- >
-
- {formatCount(tweet.replyCount)}
-
-
- onLongPressRetweet?.(tweet.id)}
- testID="retweet-button"
- className="flex-row items-center gap-1"
- >
-
-
- {formatCount(retweetCount)}
-
-
-
- onLongPressLike?.(tweet.id)}
- testID="like-button"
- className="flex-row items-center gap-1"
- >
-
-
- {formatCount(likeCount)}
-
-
-
-
-
-
-
+ onPressReply?.(tweet)}
+ onRetweetPress={() => setShowRetweetMenu(true)}
+ onLongPressLike={onLongPressLike}
+ onLongPressRetweet={onLongPressRetweet}
+ onSharePress={onShareTweet}
+ showShare
+ testIdPrefix=""
+ />
) : null}
>
@@ -975,7 +885,6 @@ export default function Tweet({
{tweet.author.displayName}
- {}
@{tweet.author.username}
@@ -985,8 +894,10 @@ export default function Tweet({
{viewerUsername !== tweet.author.username ? (
-
- {
+
+ {
closeViewer();
onPressReply?.(tweet);
}}
- testID="reply-button"
- className="flex-row items-center gap-2"
- >
-
-
- {formatCount(tweet.replyCount)}
-
-
- {
+ onRetweetPress={() => {
closeViewer();
- onLongPressRetweet?.(tweet.id);
+ setShowRetweetMenu(true);
}}
- testID="retweet-button"
- className="flex-row items-center gap-2"
- >
-
-
- {formatCount(retweetCount)}
-
-
- {
+ onLongPressLike={(id) => {
closeViewer();
- onLongPressLike?.(tweet.id);
+ onLongPressLike?.(id);
}}
- testID="like-button"
- className="flex-row items-center gap-2"
- >
-
-
- {formatCount(likeCount)}
-
-
-
-
-
+ onLongPressRetweet={(id) => {
+ closeViewer();
+ onLongPressRetweet?.(id);
+ }}
+ onSharePress={onShareTweet}
+ showShare
+ iconColor="#fff"
+ textColor="#fff"
+ iconSize={22}
+ testIdPrefix=""
+ activeColor={{
+ like: '#e0245e',
+ retweet: '#17BF63',
+ }}
+ />
@@ -1082,7 +961,9 @@ export default function Tweet({
=> {
+ try {
+ const info = await FileSystem.getInfoAsync(uri);
+ if (!info.exists || info.size === undefined || info.size === null) {
+ return { valid: true };
+ }
+ const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
+ const sizeMB = info.size / (1024 * 1024);
+ return { valid: info.size <= maxSize, sizeMB };
+ } catch {
+ return { valid: true };
+ }
+};
+
export default function TweetComposer({
visible,
onClose,
@@ -129,7 +152,6 @@ export default function TweetComposer({
mediaTypes: ['images', 'videos'],
allowsMultipleSelection: true,
selectionLimit: 4 - selectedAssets.length,
- allowsEditing: true,
quality: 1,
videoMaxDuration: 140,
exif: false,
@@ -137,28 +159,52 @@ export default function TweetComposer({
});
if (!result.canceled) {
- const newAssets: Asset[] = result.assets.map((asset) => ({
- id: asset.fileName || asset.uri,
- uri: asset.uri,
- fileName: asset.fileName ?? undefined,
- mediaType: asset.duration ? 'video' : 'photo',
- duration: asset.duration ? asset.duration / 1000 : undefined,
- }));
+ const oversizedFiles: string[] = [];
+ const validAssets: Asset[] = [];
+
+ for (const asset of result.assets) {
+ const isVideo = !!asset.duration;
+ const sizeCheck = await checkFileSize(asset.uri, isVideo);
+
+ if (!sizeCheck.valid) {
+ const maxMB = isVideo ? 10 : 5;
+ oversizedFiles.push(
+ `${asset.fileName || 'File'} (${sizeCheck.sizeMB?.toFixed(1)}MB > ${maxMB}MB)`
+ );
+ } else {
+ validAssets.push({
+ id: asset.fileName || asset.uri,
+ uri: asset.uri,
+ fileName: asset.fileName ?? undefined,
+ mediaType: isVideo ? 'video' : 'photo',
+ duration: asset.duration ? asset.duration / 1000 : undefined,
+ });
+ }
+ }
+
+ if (oversizedFiles.length > 0) {
+ Alert.alert(
+ 'File Too Large',
+ `The following files exceed size limits:\n${oversizedFiles.join('\n')}`
+ );
+ }
const availableSlots = 4 - selectedAssets.length;
- const assetsToAdd = newAssets
+ const assetsToAdd = validAssets
.filter(
(asset) =>
!selectedAssets.some((a) => (a.fileName || a.id) === (asset.fileName || asset.id))
)
.slice(0, availableSlots);
- setSelectedAssets([...assetsToAdd, ...selectedAssets]);
+ if (assetsToAdd.length > 0) {
+ setSelectedAssets([...assetsToAdd, ...selectedAssets]);
- const newForAllPicked = assetsToAdd.filter(
- (asset) => !allPickedAssets.some((a) => a.fileName === asset.fileName)
- );
- setAllPickedAssets([...allPickedAssets, ...newForAllPicked]);
+ const newForAllPicked = assetsToAdd.filter(
+ (asset) => !allPickedAssets.some((a) => a.fileName === asset.fileName)
+ );
+ setAllPickedAssets([...allPickedAssets, ...newForAllPicked]);
+ }
}
} finally {
setIsPickingMedia(false);
@@ -277,12 +323,21 @@ export default function TweetComposer({
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
quality: 1,
- allowsEditing: true,
exif: false,
});
if (!result.canceled) {
const asset = result.assets[0];
+ const sizeCheck = await checkFileSize(asset.uri, false);
+
+ if (!sizeCheck.valid) {
+ Alert.alert(
+ 'Image Too Large',
+ `This image is ${sizeCheck.sizeMB?.toFixed(1)}MB. Maximum size is 5MB.`
+ );
+ return;
+ }
+
const newAsset: Asset = {
id: asset.uri,
uri: asset.uri,
@@ -317,6 +372,16 @@ export default function TweetComposer({
if (!result.canceled) {
const asset = result.assets[0];
+ const sizeCheck = await checkFileSize(asset.uri, true);
+
+ if (!sizeCheck.valid) {
+ Alert.alert(
+ 'Video Too Large',
+ `This video is ${sizeCheck.sizeMB?.toFixed(1)}MB. Maximum size is 10MB.`
+ );
+ return;
+ }
+
const newAsset: Asset = {
id: asset.uri,
uri: asset.uri,
@@ -383,17 +448,33 @@ export default function TweetComposer({
}
// Post tweet with media IDs
- await tweetService.postTweet({
+ const response = await tweetService.postTweet({
content: tweetContent,
media: mediaIds.length > 0 ? mediaIds : undefined,
quoteToTweetId: quotedTweet?.id,
replyToTweetId: replyToTweetId || undefined,
});
- // Invalidate following feed queries to refresh the timeline
- queryClient.invalidateQueries({ queryKey: ['following-feed'] });
+ const tweetCache = getTweetCache(queryClient);
+ const { username } = user;
+
+ // Add the new tweet to cache
+ if (response.success && response.data) {
+ tweetCache.setTweet(response.data);
+ }
+
+ // If this is a reply, increment the reply count and update the replies list
+ if (replyToTweetId) {
+ tweetCache.incrementReplyCount(replyToTweetId);
+ queryClient.invalidateQueries({ queryKey: queryKeys.tweetReplies(replyToTweetId) });
+ } else {
+ // For new tweets (not replies), invalidate the following timeline to show the new tweet
+ queryClient.invalidateQueries({ queryKey: queryKeys.timeline.following(username) });
+ }
+
+ // If this is a quote, invalidate the quotes list
if (quotedTweet) {
- queryClient.invalidateQueries({ queryKey: ['tweet', quotedTweet.id] });
+ queryClient.invalidateQueries({ queryKey: queryKeys.tweetQuotes(quotedTweet.id) });
}
// Call success callback
@@ -459,6 +540,7 @@ export default function TweetComposer({
disabled={isTweetDisabled || isSubmitting}
title={isSubmitting ? 'Posting…' : 'Post'}
testID="post-button"
+ accessibilityLabel="composer-post-button"
/>
diff --git a/src/components/ui/TweetComposerContent.tsx b/src/components/ui/TweetComposerContent.tsx
index 54c730fe3..8ccbb6507 100644
--- a/src/components/ui/TweetComposerContent.tsx
+++ b/src/components/ui/TweetComposerContent.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
-import { Platform, Pressable, StyleSheet, Text, View } from 'react-native';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '@/hooks/useTheme';
import { Asset } from '@/types/media';
@@ -70,31 +70,13 @@ export default function TweetComposerContent({
const isOverLimit = tweetText.length > characterLimit;
const maxCharacterLimit = getMaxCharacterLimit(characterLimit);
- const [originalRowY, setOriginalRowY] = useState(null);
- const [replyContainerY, setReplyContainerY] = useState(null);
- const [originalAvatarYOffset, setOriginalAvatarYOffset] = useState(null);
- const [replyAvatarYOffset, setReplyAvatarYOffset] = useState(null);
- const [replySectionYOffset, setReplySectionYOffset] = useState(null);
-
- //some logic for calculating the vertical line connecting avatars
- const avatarSize = 40;
- const originalAvatarCenterY =
- originalRowY != null && originalAvatarYOffset != null
- ? originalRowY + originalAvatarYOffset + avatarSize / 2
- : null;
- const replyAvatarCenterY =
- replyContainerY != null && replySectionYOffset != null && replyAvatarYOffset != null
- ? replyContainerY + replySectionYOffset + replyAvatarYOffset + avatarSize / 2
- : null;
-
- const connectorTop = originalAvatarCenterY ?? 0;
- const connectorHeight =
- originalAvatarCenterY != null && replyAvatarCenterY != null
- ? Math.max(replyAvatarCenterY - originalAvatarCenterY, 0)
- : 0;
+ const [lineHeight, setLineHeight] = useState(0);
const composerSection = (
- <>
+
+
+
+
@@ -125,124 +108,91 @@ export default function TweetComposerContent({
)}
- >
+
);
return (
{replyToTweet && (
-
-
- {
- setOriginalRowY(e.nativeEvent.layout.y);
- }}
- >
- setOriginalAvatarYOffset(e.nativeEvent.layout.y + 25)}
- >
-
+
+ {/* Original Tweet */}
+
+
+
+
+
+
+
+ {replyToTweet.author.displayName}
+
+
+ {' @' + replyToTweet.author.username}
+
+
+ {' · ' + formatTimeToHR(replyToTweet.createdAt)}
+
-
-
-
- {replyToTweet.author.displayName}
-
-
- {' @' + replyToTweet.author.username}
-
-
- {' · ' + formatTimeToHR(replyToTweet.createdAt)}
-
-
- {replyToTweet.content && (
-
- )}
- {replyToTweet.media && replyToTweet.media.length > 0 && (
-
-
-
-
+ {replyToTweet.content && (
+
+ )}
+ {replyToTweet.media && replyToTweet.media.length > 0 && (
+
+
+
- )}
-
-
-
- {/* Vertical line connecting avatars */}
- {connectorHeight > 0 && (
-
- )}
-
- setReplyContainerY(e.nativeEvent.layout.y)}
- >
- {replyToAuthor && (
-
-
- Replying to{' '}
-
- onPressAuthor?.(replyToAuthor.username)}>
-
- {'@' + replyToAuthor.username}
-
-
)}
+
+
- setReplySectionYOffset(e.nativeEvent.layout.y)}
+ {replyToAuthor && (
+
+
+ Replying to{' '}
+
+ onPressAuthor?.(replyToAuthor.username)}
>
-
- setReplyAvatarYOffset(
- e.nativeEvent.layout.y + (Platform.OS === 'android' ? -5 : -23)
- )
- }
- >
-
-
-
- {composerSection}
-
+ {'@' + replyToAuthor.username}
+
-
-
- )}
+ )}
- {!replyToTweet && (
-
+ {/* Vertical line connecting avatars - calculated once and stays fixed */}
- setReplyAvatarYOffset(e.nativeEvent.layout.y + (Platform.OS === 'android' ? -5 : -23))
- }
+ style={[
+ styles.connectingLine,
+ {
+ backgroundColor: currentColors.foreground,
+ height: lineHeight,
+ },
+ ]}
+ />
+
+ {
+ if (lineHeight === 0) {
+ e.target.measure((x, y, width, height, pageX, pageY) => {
+ const originalAvatarBottom = 48;
+ const replyAvatarTop = pageY;
+ const calculatedHeight = Math.max(0, replyAvatarTop - originalAvatarBottom - 90);
+ setLineHeight(calculatedHeight);
+ });
+ }
+ }}
>
-
+ {composerSection}
-
- {composerSection}
)}
+ {!replyToTweet && composerSection}
);
}
@@ -252,16 +202,16 @@ const styles = StyleSheet.create({
padding: 16,
flex: 1,
},
- threadStack: {
- flexDirection: 'column',
+ threadContainer: {
+ position: 'relative',
},
originalTweetRow: {
flexDirection: 'row',
gap: 12,
+ marginBottom: 8,
},
avatarColumn: {
width: 40,
- alignItems: 'center',
},
originalTweetContent: {
flex: 1,
@@ -292,32 +242,21 @@ const styles = StyleSheet.create({
backgroundColor: 'transparent',
},
connectingLine: {
+ position: 'absolute',
width: 2,
+ left: 20,
+ top: 48,
opacity: 0.5,
},
- connectingLineAbsolute: {
- position: 'absolute',
- left: 19,
- },
- replyContainer: {
- marginTop: 25,
- },
replyingToContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
marginLeft: 52,
},
- replySection: {
+ composerWrapper: {
flexDirection: 'row',
gap: 12,
- flex: 1,
- alignItems: 'flex-start',
- },
- regularComposerSection: {
- flexDirection: 'row',
- gap: 12,
- flex: 1,
alignItems: 'flex-start',
},
replyingToText: {
@@ -329,10 +268,8 @@ const styles = StyleSheet.create({
},
avatarContainer: {
width: 40,
- },
- replyAvatarContainer: {
- width: 40,
- marginTop: Platform.OS === 'android' ? -20 : 0,
+
+ paddingTop: -2,
},
contentWrapper: {
flex: 1,
@@ -341,10 +278,12 @@ const styles = StyleSheet.create({
flex: 1,
},
textInput: {
- flex: 1,
fontSize: 16,
- paddingTop: 12,
+ paddingTop: 8,
+ paddingBottom: 12,
+ paddingHorizontal: 0,
textAlignVertical: 'top',
+ minHeight: 100,
},
mediaContainer: {
marginTop: 12,
diff --git a/src/components/ui/TweetContent.tsx b/src/components/ui/TweetContent.tsx
index 5fdf407f2..cce46cb99 100644
--- a/src/components/ui/TweetContent.tsx
+++ b/src/components/ui/TweetContent.tsx
@@ -57,20 +57,21 @@ export default function TweetContent({
const value = e.key;
const len = value.length;
- const matchIndex = text.indexOf(value, current);
+ const matchIndex = text.toLowerCase().indexOf(value.toLowerCase(), current);
if (matchIndex === -1) continue;
if (matchIndex > current)
parts.push({ content: text.slice(current, matchIndex), type: 'text' });
- parts.push({ content: text.slice(matchIndex, matchIndex + len), type: e.type, value });
+ const actualText = text.slice(matchIndex, matchIndex + len);
+ parts.push({ content: actualText, type: e.type, value: actualText });
current = matchIndex + len;
}
if (current < text.length) parts.push({ content: text.slice(current), type: 'text' });
return (
-
+
{parts
.flatMap((p) => (p.type === 'text' ? splitTextByLinks(p.content) : [p]))
.map((p, idx) => {
@@ -79,6 +80,7 @@ export default function TweetContent({
onPressMention?.(p.value!.slice(1))}
>
{p.content}
@@ -89,6 +91,7 @@ export default function TweetContent({
onPressHashtag?.(p.value!.slice(1))}
>
{p.content}
@@ -99,6 +102,7 @@ export default function TweetContent({
Linking.openURL(p.value!).catch(() => undefined)}
>
{p.content}
diff --git a/src/components/ui/TweetMediaGrid.styles.ts b/src/components/ui/TweetMediaGrid.styles.ts
index 482ff686c..a202f5e81 100644
--- a/src/components/ui/TweetMediaGrid.styles.ts
+++ b/src/components/ui/TweetMediaGrid.styles.ts
@@ -2,11 +2,11 @@ import { StyleSheet } from 'react-native';
export const tweetMediaGridStyles = StyleSheet.create({
image: { borderRadius: 8, backgroundColor: '#d1d9de' },
- singleContainer: { borderRadius: 12 },
- singleContainerNoShrink: { flexShrink: 0 },
- aspectRatioWrapper: { width: '100%' },
+ imageFill: { flex: 1, width: '100%' },
+ singleContainer: { borderRadius: 12, overflow: 'hidden' },
row180: { height: 180 },
row220: { height: 220 },
+ row280: { height: 280 },
// compact sizes for small previews
row106: { height: 106 },
videoContainer: {
diff --git a/src/components/ui/TweetMediaGrid.tsx b/src/components/ui/TweetMediaGrid.tsx
index 667108b4c..f3879767e 100644
--- a/src/components/ui/TweetMediaGrid.tsx
+++ b/src/components/ui/TweetMediaGrid.tsx
@@ -1,6 +1,7 @@
-import { Image, Pressable, View } from 'react-native';
+import { Pressable, View } from 'react-native';
import Ionicons from '@expo/vector-icons/Ionicons';
+import { Image } from 'expo-image';
import { tweetMediaGridStyles } from './TweetMediaGrid.styles';
import VideoPlayer from './VideoPlayer';
@@ -11,9 +12,10 @@ type Props = {
media: MediaItem[];
onPressItem?: (index: number, item: MediaItem) => void;
compact?: boolean;
+ tweetId?: string;
};
-export default function TweetMediaGrid({ media, onPressItem, compact }: Props) {
+export default function TweetMediaGrid({ media, onPressItem, compact, tweetId }: Props) {
if (!media || media.length === 0) return null;
const count = Math.min(media.length, 4);
@@ -32,7 +34,7 @@ export default function TweetMediaGrid({ media, onPressItem, compact }: Props) {
)}
);
if (count === 1) {
- const item = items[0];
- const aspectRatio = item.width && item.height ? item.width / item.height : 16 / 9;
return (
-
- {renderMedia(item, 0, {
- aspectRatio,
- })}
-
+ {renderMedia(items[0], 0)}
);
}
diff --git a/src/components/ui/TweetSectionList.tsx b/src/components/ui/TweetSectionList.tsx
index a24d62141..bfe63efba 100644
--- a/src/components/ui/TweetSectionList.tsx
+++ b/src/components/ui/TweetSectionList.tsx
@@ -4,6 +4,7 @@ import { Text, View } from 'react-native';
import { useRootNavigation } from '@/hooks/navigation/useRootNavigation';
import { Categary } from '@/services/explore';
+import { isViewingSameProfile } from '@/utils/navigation/isViewingSameProfile';
import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames';
import AppText from './AppText';
@@ -20,11 +21,14 @@ const TweetSectionList = ({ sections, onQuote }: TweetSectionListProps) => {
const rootNavigation = useRootNavigation();
const goToProfile = useCallback(
- (username: string) =>
- rootNavigation.navigate(ROOT.PROFILE, {
+ (username: string) => {
+ if (isViewingSameProfile(username)) return;
+
+ rootNavigation.push(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username },
- }),
+ });
+ },
[rootNavigation]
);
@@ -44,17 +48,21 @@ const TweetSectionList = ({ sections, onQuote }: TweetSectionListProps) => {
const ItemSeparator = memo(() => );
ItemSeparator.displayName = 'ItemSeparator';
+ if (!sections || sections.length === 0) {
+ return null;
+ }
+
return (
- {sections.map((section) => (
-
+ {(sections ?? []).map((section, sectionIndex) => (
+
{section.category}
{/* Tweets */}
- {section.tweets.map((tweet, index) => (
-
+ {(section.tweets ?? []).map((tweet, tweetIndex) => (
+
{
onPressTweet={handleOpenTweetDetail}
onQuote={onQuote}
/>
- {index < section.tweets.length - 1 && }
+ {tweetIndex < (section.tweets?.length ?? 0) - 1 && }
))}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 7ba3800fe..93a6a87df 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1,9 +1,15 @@
export { default as AppText } from './AppText';
export { default as Button } from './Button';
export { default as ConfirmationDialog } from './ConfirmationDialog';
+export { default as ConfirmationModal } from './ConfirmationModal';
export { default as DatePicker } from './DatePicker';
export { EmailInput } from './EmailInput';
+export { default as Logo } from './Logo';
export { default as ModalOverlay } from './ModalOverlay';
export { PasswordInput } from './PasswordInput';
+export { default as Spinner } from './Spinner';
export { TextInput } from './TextInput';
+export { MemoizedTweetItem, default as TimelineFeedList } from './TimelineFeedList';
+export { default as TrendsList } from './TrendsList';
export { default as Tweet } from './Tweet';
+export { default as TweetSectionList } from './TweetSectionList';
diff --git a/src/components/utils/Captcha.tsx b/src/components/utils/Captcha.tsx
index 1c3dc13c5..7407cfa48 100644
--- a/src/components/utils/Captcha.tsx
+++ b/src/components/utils/Captcha.tsx
@@ -9,6 +9,7 @@ import { colors } from '@/utils/colorTheme';
import generateCaptchaHtml from './captchaTemplate';
+/* istanbul ignore next */
const patchPostMessageJsCode = `(${String(function () {
const win = window;
const originalPostMessage = win.ReactNativeWebView.postMessage;
diff --git a/src/hooks/navigation/useBottomTabsConfig.tsx b/src/hooks/navigation/useBottomTabsConfig.tsx
index 67195d5a0..f6bca5367 100644
--- a/src/hooks/navigation/useBottomTabsConfig.tsx
+++ b/src/hooks/navigation/useBottomTabsConfig.tsx
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react';
import { Platform, Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native';
-import { Feather, Ionicons, MaterialCommunityIcons, MaterialIcons } from '@expo/vector-icons';
+import { Ionicons, MaterialCommunityIcons, MaterialIcons } from '@expo/vector-icons';
import { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs';
import { DrawerNavigationProp } from '@react-navigation/drawer';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -102,6 +102,7 @@ export const useBottomTabsConfig = () => {
className="w-full self-center"
style={searchStyles.searchBarWrapper}
testID="explore-search-trigger"
+ accessibilityLabel="explore-search-trigger"
accessibilityRole="button"
>
{
borderBottomWidth: StyleSheet.hairlineWidth,
elevation: 0,
paddingTop: insets.top,
- paddingBottom: Platform.OS === 'ios' ? 10 : Math.max(0, insets.bottom - 14),
+ paddingBottom: Platform.OS === 'ios' ? 10 : Math.max(0, insets.bottom),
paddingLeft: insets.left,
paddingRight: insets.right,
};
@@ -178,8 +179,8 @@ export const useBottomTabsConfig = () => {
header: () => {
return (
-
-
+
+
(
@@ -198,23 +199,7 @@ export const useBottomTabsConfig = () => {
/>
-
- {renderHeaderTitle()}
-
- {currentRouteName === BOTTOM_TABS.NOTIFICATIONS && (
-
- {
- rootNavigation.navigate(ROOT.SETTINGS, {
- screen: BOTTOM_TABS.NOTIFICATIONS,
- });
- }}
- testID="SettingsNavigator"
- >
-
-
-
- )}
+ {renderHeaderTitle()}
);
@@ -239,7 +224,10 @@ export const useBottomTabsConfig = () => {
{icon}
-
+
{badgeCount > 99 ? '99+' : `${badgeCount}`}
diff --git a/src/hooks/navigation/useTopTabsConfig.tsx b/src/hooks/navigation/useTopTabsConfig.tsx
index e4ea8f390..25ec1375c 100644
--- a/src/hooks/navigation/useTopTabsConfig.tsx
+++ b/src/hooks/navigation/useTopTabsConfig.tsx
@@ -33,6 +33,8 @@ export const useTopTabsConfig = () => {
tabBarContentContainerStyle: {
borderBottomColor: theme === 'light' ? colorscheme.border : colorscheme.mutedForeground,
borderBottomWidth: theme === 'light' ? 1 : 0.3,
+ justifyContent: 'center',
+ alignItems: 'center',
},
tabBarActiveTintColor: colorscheme.foreground,
tabBarInactiveTintColor: colorscheme.mutedForeground,
diff --git a/src/hooks/notifications/useNotifications.ts b/src/hooks/notifications/useNotifications.ts
index 7fd548154..a9e37334f 100644
--- a/src/hooks/notifications/useNotifications.ts
+++ b/src/hooks/notifications/useNotifications.ts
@@ -1,15 +1,44 @@
-import { useInfiniteQuery } from '@tanstack/react-query';
+import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
import { getNotifications } from '@/services/notifications';
import { useUserStore } from '@/stores/userStore';
+import type { TweetNotification } from '@/types/notifications';
+
export const useNotifications = (filter?: string, limit: number = 20) => {
const { username } = useUserStore((state) => state.user);
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
return useInfiniteQuery({
- queryKey: ['notifications', { username, filter, limit }],
+ queryKey: queryKeys.notifications(username, filter),
queryFn: async ({ pageParam }: { pageParam: string | null }) => {
- return getNotifications(filter, pageParam ?? undefined, limit);
+ const result = await getNotifications(filter, pageParam ?? undefined, limit);
+
+ // Populate the tweet cache with tweets from notifications
+ if (result.data && Array.isArray(result.data)) {
+ try {
+ const tweets = result.data
+ .filter(
+ (notification): notification is TweetNotification => 'tweetSummary' in notification
+ )
+ .map((notification) => notification.tweetSummary.primaryTweet)
+ .filter((tweet) => {
+ // Filter out null tweets
+ return tweet != null;
+ });
+
+ if (tweets.length > 0) {
+ tweetCache.setTweets(tweets);
+ }
+ } catch (error) {
+ console.error('[useNotifications] Error populating cache:', error);
+ }
+ }
+
+ return result;
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.pagination.nextCursor ?? null,
diff --git a/src/hooks/notifications/useNotificationsSse.ts b/src/hooks/notifications/useNotificationsSse.ts
deleted file mode 100644
index a620eba67..000000000
--- a/src/hooks/notifications/useNotificationsSse.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { useMemo } from 'react';
-
-import { useQueryClient } from '@tanstack/react-query';
-
-import { useSSE } from '@/hooks/useSSE';
-
-type NotificationCustomEvents = 'notifications.count_update' | 'notifications.new';
-
-interface NotificationCountUpdateEvent {
- count: number;
-}
-
-export function useNotificationsSse(enabled: boolean) {
- const queryClient = useQueryClient();
-
- const eventHandlers = useMemo(() => {
- const countUpdateListener = (event: { data?: string | null }) => {
- if (!event.data) return;
-
- try {
- const data = JSON.parse(event.data) as NotificationCountUpdateEvent;
-
- queryClient.setQueriesData(
- {
- predicate: (query) =>
- query.queryKey[0] === 'notifications' && query.queryKey[1] === 'count',
- },
- (old: unknown) => {
- if (!old || typeof old !== 'object') return { data: { unseenCount: data.count } };
-
- return { ...old, data: { unseenCount: data.count } };
- }
- );
- } catch (error) {
- console.warn('[Notifications SSE] Failed to parse count_update event:', error);
- }
- };
-
- const newNotificationListener = (event: { data?: string | null }) => {
- if (!event.data) return;
-
- try {
- // Invalidate both count and notifications list
- queryClient.invalidateQueries({ queryKey: ['notifications', 'count'] });
- queryClient.invalidateQueries({ queryKey: ['notifications'] });
- } catch (error) {
- console.warn('[Notifications SSE] Failed to parse new notification event:', error);
- }
- };
-
- return [
- {
- event: 'notifications.count_update' as const,
- handler: countUpdateListener,
- },
- {
- event: 'notifications.new' as const,
- handler: newNotificationListener,
- },
- ];
- }, [queryClient]);
-
- const { isConnected } = useSSE({
- topics: ['notifications'],
- enabled,
- eventHandlers,
- pollingInterval: 5000,
- debug: false,
- });
-
- return {
- isConnected,
- };
-}
diff --git a/src/hooks/notifications/usePushNotifications.ts b/src/hooks/notifications/usePushNotifications.ts
index 68ed0ce3a..18f15f221 100644
--- a/src/hooks/notifications/usePushNotifications.ts
+++ b/src/hooks/notifications/usePushNotifications.ts
@@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import Constants from 'expo-constants';
import * as Device from 'expo-device';
-import { useNotificationsSse } from '@/hooks/notifications/useNotificationsSse';
+import { useRealtimeEvents } from '@/hooks/notifications/useRealtimeEvents';
import { navigationRef } from '@/navigation/navigationRef';
import { FcmNotificationData, NotificationType } from '@/types/notifications';
import {
@@ -161,21 +161,15 @@ export function usePushNotifications() {
const [granted, setGranted] = useState(false);
const [useFcm, setUseFcm] = useState(false);
const queryClient = useQueryClient();
+ const shouldUseSSE = Platform.OS === 'ios' || !Device.isDevice || isExpoGo || !pushToken;
- // Determine if we should use SSE as fallback
- // Use SSE on iOS, emulators, Expo Go, or when no FCM token is available
- const shouldUseSse = Platform.OS === 'ios' || !Device.isDevice || isExpoGo || !pushToken;
-
- // Enable SSE when FCM is not available or not granted
- useNotificationsSse(shouldUseSse && !useFcm);
+ useRealtimeEvents(shouldUseSSE && !useFcm);
useEffect(() => {
registerForPushNotificationsAsync()
.then(({ pushTokenString, granted: permissionGranted }) => {
setPushToken(pushTokenString);
setGranted(permissionGranted);
-
- // If we got a valid token and permission, we can use FCM
setUseFcm(!!pushTokenString && permissionGranted);
})
.catch((e) => {
@@ -184,10 +178,7 @@ export function usePushNotifications() {
setUseFcm(false);
});
- // Skip Firebase Messaging listeners in Expo Go or when SSE is preferred
- if (isExpoGo || shouldUseSse) {
- return;
- }
+ if (isExpoGo || shouldUseSSE) return;
// Setup Firebase listeners
const setupFirebaseListeners = async () => {
@@ -244,11 +235,11 @@ export function usePushNotifications() {
return () => {
cleanup?.();
};
- }, [queryClient, shouldUseSse]);
+ }, [queryClient, shouldUseSSE]);
return {
pushToken,
granted,
- usingSse: shouldUseSse && !useFcm,
+ usingSse: shouldUseSSE,
};
}
diff --git a/src/hooks/useDmSse.ts b/src/hooks/notifications/useRealtimeEvents.ts
similarity index 56%
rename from src/hooks/useDmSse.ts
rename to src/hooks/notifications/useRealtimeEvents.ts
index 244d07a97..b1d2a782c 100644
--- a/src/hooks/useDmSse.ts
+++ b/src/hooks/notifications/useRealtimeEvents.ts
@@ -5,12 +5,23 @@ import { useQueryClient } from '@tanstack/react-query';
import { useSSE } from '@/hooks/useSSE';
import { dmKeys } from '@/services/dm';
import { useDmStore } from '@/stores/dmStore';
+import { useTimelineStore } from '@/stores/timelineStore';
import { Conversation, DmNewMessageEvent, DmUnseenCountEvent } from '@/types/dm';
import type { ApiSuccessResponse } from '@/libs/api';
import type { InfiniteData } from '@tanstack/react-query';
-type DmCustomEvents = 'dm.unseen_conversations_count' | 'dm.new_message';
+type SseCustomEvents =
+ | 'notifications.count_update'
+ | 'notifications.new'
+ | 'notifications.delete'
+ | 'dm.unseen_conversations_count'
+ | 'dm.new_message'
+ | 'timeline.following';
+
+interface TimelineFollowingEvent {
+ authors: string[] | null;
+}
type ConversationListCache =
| InfiniteData>
@@ -33,46 +44,49 @@ const isSuccessConversationResponse = (
return 'data' in value && !isInfiniteConversationData(value as ConversationListCache);
};
-export function useDmSse(activeAccountId: string | null) {
- const client = useQueryClient();
+export function useRealtimeEvents(enableNotifications: boolean) {
+ const queryClient = useQueryClient();
+
+ // DM store
const setUnseenCount = useDmStore((s) => s.setUnseenCount);
const activeConversationId = useDmStore((s) => s.activeConversationId);
- const lastAccountIdRef = useRef(null);
const activeConversationIdRef = useRef(activeConversationId);
+ // Timeline store
+ const setFollowingNewTweetAuthors = useTimelineStore((s) => s.setFollowingNewTweetAuthors);
+
// Keep activeConversationIdRef in sync
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
}, [activeConversationId]);
- // Handle account switching - reset unseen count
- useEffect(() => {
- const previousAccountId = lastAccountIdRef.current;
- const accountBecameInactive = Boolean(previousAccountId && !activeAccountId);
- const switchedAccounts = Boolean(
- previousAccountId && activeAccountId && previousAccountId !== activeAccountId
- );
-
- if (accountBecameInactive || switchedAccounts) {
- setUnseenCount(0);
- }
+ const eventHandlers = useMemo(() => {
+ // Notifications handlers
+ const newNotificationListener = (event: { data?: string | null }) => {
+ if (!event.data) return;
- lastAccountIdRef.current = activeAccountId;
- }, [activeAccountId, setUnseenCount]);
+ try {
+ // Invalidate both count and notifications list
+ queryClient.invalidateQueries({ queryKey: ['notifications', 'count'] });
+ queryClient.invalidateQueries({ queryKey: ['notifications'] });
+ } catch (error) {
+ console.warn('[SSE] Failed to parse notifications.new event:', error);
+ }
+ };
- const eventHandlers = useMemo(() => {
- const unseenListener = (event: { data?: string | null }) => {
+ // DM handlers
+ const dmUnseenListener = (event: { data?: string | null }) => {
if (!event.data) return;
try {
const data = JSON.parse(event.data) as DmUnseenCountEvent;
setUnseenCount(data.count ?? 0);
} catch (error) {
- console.warn('[DM SSE] Failed to parse unseen_conversations_count event:', error);
+ console.warn('[SSE] Failed to parse dm.unseen_conversations_count event:', error);
}
};
- const newMessageListener = (event: { data?: string | null }) => {
+ const dmNewMessageListener = (event: { data?: string | null }) => {
if (!event.data) return;
try {
@@ -101,7 +115,7 @@ export function useDmSse(activeAccountId: string | null) {
return [updatedConversation, ...before, ...after];
};
- client.setQueryData(dmKeys.conversations(), (old: ConversationListCache) => {
+ queryClient.setQueryData(dmKeys.conversations(), (old: ConversationListCache) => {
if (!old) return old;
if (Array.isArray(old)) {
@@ -129,30 +143,77 @@ export function useDmSse(activeAccountId: string | null) {
});
if (!conversationFound) {
- client.invalidateQueries({ queryKey: dmKeys.conversations() });
+ queryClient.invalidateQueries({ queryKey: dmKeys.conversations() });
}
} catch (error) {
- console.warn('[DM SSE] Failed to parse new_message event:', error);
+ console.warn('[SSE] Failed to parse dm.new_message event:', error);
}
};
- return [
+ // Timeline handlers
+ const timelineFollowingListener = (event: { data?: string | null }) => {
+ if (!event.data) return;
+
+ try {
+ const data = JSON.parse(event.data) as TimelineFollowingEvent;
+ setFollowingNewTweetAuthors(data.authors);
+ } catch (error) {
+ console.warn('[SSE] Failed to parse timeline.following event:', error);
+ }
+ };
+
+ const handlers = [] as {
+ event: SseCustomEvents;
+ handler: (event: { data?: string | null }) => void;
+ }[];
+
+ if (enableNotifications)
+ handlers.push(
+ {
+ event: 'notifications.count_update',
+ handler: newNotificationListener,
+ },
+ {
+ event: 'notifications.delete',
+ handler: newNotificationListener,
+ },
+ {
+ event: 'notifications.new',
+ handler: newNotificationListener,
+ }
+ );
+
+ handlers.push(
{
- event: 'dm.unseen_conversations_count' as const,
- handler: unseenListener,
+ event: 'dm.unseen_conversations_count',
+ handler: dmUnseenListener,
},
{
- event: 'dm.new_message' as const,
- handler: newMessageListener,
+ event: 'dm.new_message',
+ handler: dmNewMessageListener,
},
- ];
- }, [client, setUnseenCount]);
+ {
+ event: 'timeline.following',
+ handler: timelineFollowingListener,
+ }
+ );
- useSSE({
- topics: ['dm'],
- enabled: !!activeAccountId,
+ return handlers;
+ }, [enableNotifications, queryClient, setUnseenCount, setFollowingNewTweetAuthors]);
+
+ const topics = useMemo(
+ () => (enableNotifications ? ['notifications', 'dm', 'timeline'] : ['dm', 'timeline']),
+ [enableNotifications]
+ );
+
+ const { isConnected } = useSSE({
+ topics,
eventHandlers,
pollingInterval: 5000,
debug: false,
});
+
+ return {
+ isConnected,
+ };
}
diff --git a/src/hooks/profile/useBlockMutation.tsx b/src/hooks/profile/useBlockMutation.tsx
index 8f51c017a..b9d697024 100644
--- a/src/hooks/profile/useBlockMutation.tsx
+++ b/src/hooks/profile/useBlockMutation.tsx
@@ -1,10 +1,13 @@
import { InfiniteData, QueryClient, useMutation, useQueryClient } from '@tanstack/react-query';
import { ApiException, ApiResponseBase } from '@/libs/api';
+import { queryKeys } from '@/libs/queryKeys';
import { blockUser, unblockUser } from '@/services/me';
import { ListResponse } from '@/services/settings';
+import { GetTweetLikesResponse, GetTweetRetweetersResponse } from '@/services/tweets';
import { useUserStore } from '@/stores/userStore';
import { UserProfile } from '@/types/user';
+import { updateSearchUsersCache } from '@/utils/updateSearchCache';
export type BlockMutationVariables = {
username: string;
@@ -20,11 +23,13 @@ export type BlockMutationContext = {
};
type InfiniteListResponse = InfiniteData;
+type TweetLikersInfiniteResponse = InfiniteData;
+type TweetRetweetersInfiniteResponse = InfiniteData;
function updateLists(queryClient: QueryClient, username: string, isBlocked: boolean) {
- const queryKeys: ('blocks' | 'mutes')[] = ['blocks', 'mutes'];
+ const listKeys: ('blocks' | 'mutes')[] = ['blocks', 'mutes'];
- for (const key of queryKeys) {
+ for (const key of listKeys) {
const queries = queryClient.getQueriesData({
queryKey: [key],
});
@@ -54,6 +59,66 @@ function updateLists(queryClient: QueryClient, username: string, isBlocked: bool
}
}
+function updateTweetLikersAndRetweetersLists(
+ queryClient: QueryClient,
+ username: string,
+ isBlocked: boolean
+) {
+ const likersQueries = queryClient.getQueriesData({
+ predicate: (query) => query.queryKey[0] === queryKeys.tweetLikers('')[0],
+ });
+
+ likersQueries.forEach(([queryKey, data]) => {
+ if (!data) return;
+
+ const pages = data.pages.map((page) => ({
+ ...page,
+ data: page.data.map((user) =>
+ user.username === username
+ ? {
+ ...user,
+ relationship: {
+ ...user.relationship,
+ blocking: isBlocked,
+ follower: isBlocked ? false : user.relationship?.follower,
+ following: isBlocked ? false : user.relationship?.following,
+ },
+ }
+ : user
+ ),
+ }));
+
+ queryClient.setQueryData(queryKey, { ...data, pages });
+ });
+
+ const retweetersQueries = queryClient.getQueriesData({
+ predicate: (query) => query.queryKey[0] === queryKeys.tweetRetweeters('')[0],
+ });
+
+ retweetersQueries.forEach(([queryKey, data]) => {
+ if (!data) return;
+
+ const pages = data.pages.map((page) => ({
+ ...page,
+ data: page.data.map((user) =>
+ user.username === username
+ ? {
+ ...user,
+ relationship: {
+ ...user.relationship,
+ blocking: isBlocked,
+ follower: isBlocked ? false : user.relationship?.follower,
+ following: isBlocked ? false : user.relationship?.following,
+ },
+ }
+ : user
+ ),
+ }));
+
+ queryClient.setQueryData(queryKey, { ...data, pages });
+ });
+}
+
export function useBlockMutation() {
const queryClient = useQueryClient();
const updateUser = useUserStore((state) => state.updateUser);
@@ -109,6 +174,7 @@ export function useBlockMutation() {
});
updateLists(queryClient, username, block);
+ updateTweetLikersAndRetweetersLists(queryClient, username, block);
updateUser({
followersCount: newViewerFollowersCount,
@@ -116,6 +182,12 @@ export function useBlockMutation() {
});
}
}
+
+ updateSearchUsersCache(queryClient, username, {
+ blocking: block,
+ follower: block ? false : previousProfile?.relationship?.follower,
+ following: block ? false : previousProfile?.relationship?.following,
+ });
}
return {
@@ -128,9 +200,16 @@ export function useBlockMutation() {
onError: (_error, { username }, context) => {
if (!context) return;
- if (context.previousProfile)
+ if (context.previousProfile) {
queryClient.setQueryData(['profile', username], context.previousProfile);
+ updateSearchUsersCache(queryClient, username, {
+ blocking: context.previousProfile.relationship?.blocking,
+ follower: context.previousProfile.relationship?.follower,
+ following: context.previousProfile.relationship?.following,
+ });
+ }
+
if (context.previousViewerProfile)
queryClient.setQueryData(
['profile', context.viewerUsername],
diff --git a/src/hooks/profile/useFollowMutation.ts b/src/hooks/profile/useFollowMutation.ts
index a3811f6ea..75bd6ccd9 100644
--- a/src/hooks/profile/useFollowMutation.ts
+++ b/src/hooks/profile/useFollowMutation.ts
@@ -1,10 +1,12 @@
import { InfiniteData, QueryClient, useMutation, useQueryClient } from '@tanstack/react-query';
import { ApiException } from '@/libs/api';
+import { queryKeys } from '@/libs/queryKeys';
import { followUser, unfollowUser } from '@/services/connections';
import { GetTweetLikesResponse, GetTweetRetweetersResponse } from '@/services/tweets';
import { useUserStore } from '@/stores/userStore';
import { GetUserFollowersResponse, GetUserFollowingResponse, UserProfile } from '@/types/user';
+import { updateSearchUsersCache } from '@/utils/updateSearchCache';
type FollowActionResponse = {
success: true;
@@ -56,7 +58,6 @@ export function updateConnectionsLists(
user.username === username
? {
...user,
- isFollowing,
relationship: {
...user.relationship,
following: isFollowing,
@@ -77,7 +78,7 @@ function updateTweetLikersAndRetweetersLists(
isFollowing: boolean
) {
const likersQueries = queryClient.getQueriesData({
- queryKey: ['tweetLikers'],
+ predicate: (query) => query.queryKey[0] === queryKeys.tweetLikers('')[0],
});
likersQueries.forEach(([queryKey, data]) => {
@@ -85,14 +86,24 @@ function updateTweetLikersAndRetweetersLists(
const pages = data.pages.map((page) => ({
...page,
- data: page.data.map((user) => (user.username === username ? { ...user, isFollowing } : user)),
+ data: page.data.map((user) =>
+ user.username === username
+ ? {
+ ...user,
+ relationship: {
+ ...user.relationship,
+ following: isFollowing,
+ },
+ }
+ : user
+ ),
}));
queryClient.setQueryData(queryKey, { ...data, pages });
});
const retweetersQueries = queryClient.getQueriesData({
- queryKey: ['tweetRetweeters'],
+ predicate: (query) => query.queryKey[0] === queryKeys.tweetRetweeters('')[0],
});
retweetersQueries.forEach(([queryKey, data]) => {
@@ -100,7 +111,17 @@ function updateTweetLikersAndRetweetersLists(
const pages = data.pages.map((page) => ({
...page,
- data: page.data.map((user) => (user.username === username ? { ...user, isFollowing } : user)),
+ data: page.data.map((user) =>
+ user.username === username
+ ? {
+ ...user,
+ relationship: {
+ ...user.relationship,
+ following: isFollowing,
+ },
+ }
+ : user
+ ),
}));
queryClient.setQueryData(queryKey, { ...data, pages });
@@ -167,7 +188,7 @@ export function useFollowMutation() {
blocking: false,
blockedBy: false,
muted: false,
- follower: null,
+ follower: undefined,
following: follow,
},
});
@@ -195,6 +216,7 @@ export function useFollowMutation() {
updateConnectionsLists(queryClient, username, follow);
updateTweetLikersAndRetweetersLists(queryClient, username, follow);
updateMutualsList(queryClient, username, follow);
+ updateSearchUsersCache(queryClient, username, { following: follow });
}
const contextValue: FollowMutationContext = {
@@ -229,6 +251,9 @@ export function useFollowMutation() {
updateConnectionsLists(queryClient, variables.username, Boolean(variables.previous));
updateTweetLikersAndRetweetersLists(queryClient, variables.username, !!variables.previous);
+ updateSearchUsersCache(queryClient, variables.username, {
+ following: !!variables.previous,
+ });
}
},
diff --git a/src/hooks/profile/useMuteMutation.tsx b/src/hooks/profile/useMuteMutation.tsx
index b0e3d10d4..64fbad970 100644
--- a/src/hooks/profile/useMuteMutation.tsx
+++ b/src/hooks/profile/useMuteMutation.tsx
@@ -5,6 +5,7 @@ import { muteUser, unmuteUser } from '@/services/me';
import { ListResponse } from '@/services/settings';
import { useUserStore } from '@/stores/userStore';
import { UserProfile } from '@/types/user';
+import { updateSearchUsersCache } from '@/utils/updateSearchCache';
export type MuteMutationVariables = {
username: string;
@@ -86,6 +87,7 @@ export function useMuteMutation() {
queryClient.setQueryData(['profile', viewerUsername], previousViewerProfile);
}
updateLists(queryClient, username, mute);
+ updateSearchUsersCache(queryClient, username, { muted: mute });
}
return {
@@ -113,6 +115,7 @@ export function useMuteMutation() {
const mute = !!context.previousProfile?.relationship?.muted;
updateLists(queryClient, username, mute);
+ updateSearchUsersCache(queryClient, username, { muted: mute });
}
},
diff --git a/src/hooks/profile/useUserLikes.tsx b/src/hooks/profile/useUserLikes.tsx
index 6fc8e1b18..9215d682f 100644
--- a/src/hooks/profile/useUserLikes.tsx
+++ b/src/hooks/profile/useUserLikes.tsx
@@ -1,11 +1,25 @@
-import { useInfiniteQuery } from '@tanstack/react-query';
+import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
import { getUserLikes } from '@/services/users';
export const useUserLikes = (username: string) => {
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
+
return useInfiniteQuery({
- queryKey: ['userLikes', username],
- queryFn: ({ pageParam }) => getUserLikes(username, pageParam),
+ queryKey: queryKeys.user.likes(username),
+ queryFn: async ({ pageParam }) => {
+ const result = await getUserLikes(username, pageParam);
+
+ // Populate the tweet cache with fetched tweets
+ if (result.data && Array.isArray(result.data)) {
+ tweetCache.setTweets(result.data);
+ }
+
+ return result;
+ },
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => {
return lastPage.pagination.hasNextPage ? lastPage.pagination.nextCursor : undefined;
diff --git a/src/hooks/profile/useUserMedia.tsx b/src/hooks/profile/useUserMedia.tsx
index ed59a89c3..399dd6561 100644
--- a/src/hooks/profile/useUserMedia.tsx
+++ b/src/hooks/profile/useUserMedia.tsx
@@ -1,11 +1,25 @@
-import { useInfiniteQuery } from '@tanstack/react-query';
+import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
import { getUserMedia } from '@/services/users';
export const useUserMedia = (username: string) => {
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
+
return useInfiniteQuery({
- queryKey: ['userMedia', username],
- queryFn: ({ pageParam }) => getUserMedia(username, pageParam),
+ queryKey: queryKeys.user.media(username),
+ queryFn: async ({ pageParam }) => {
+ const result = await getUserMedia(username, pageParam);
+
+ // Populate the tweet cache with fetched tweets
+ if (result.data && Array.isArray(result.data)) {
+ tweetCache.setTweets(result.data);
+ }
+
+ return result;
+ },
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => {
return lastPage.pagination.hasNextPage ? lastPage.pagination.nextCursor : undefined;
diff --git a/src/hooks/profile/useUserReplies.tsx b/src/hooks/profile/useUserReplies.tsx
index 492d7f3d4..5af4dea24 100644
--- a/src/hooks/profile/useUserReplies.tsx
+++ b/src/hooks/profile/useUserReplies.tsx
@@ -1,11 +1,25 @@
-import { useInfiniteQuery } from '@tanstack/react-query';
+import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
import { getUserReplies } from '@/services/users';
export const useUserReplies = (username: string) => {
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
+
return useInfiniteQuery({
- queryKey: ['userReplies', username],
- queryFn: ({ pageParam }) => getUserReplies(username, pageParam),
+ queryKey: queryKeys.user.replies(username),
+ queryFn: async ({ pageParam }) => {
+ const result = await getUserReplies(username, pageParam);
+
+ // Populate the tweet cache with fetched tweets
+ if (result.data && Array.isArray(result.data)) {
+ tweetCache.setTweets(result.data);
+ }
+
+ return result;
+ },
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => {
return lastPage.pagination.hasNextPage ? lastPage.pagination.nextCursor : undefined;
diff --git a/src/hooks/profile/useUserTweets.tsx b/src/hooks/profile/useUserTweets.tsx
index f24f3dbff..ba0b7a22a 100644
--- a/src/hooks/profile/useUserTweets.tsx
+++ b/src/hooks/profile/useUserTweets.tsx
@@ -1,11 +1,25 @@
-import { useInfiniteQuery } from '@tanstack/react-query';
+import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
import { getUserTweets } from '@/services/users';
export const useUserTweets = (username: string) => {
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
+
return useInfiniteQuery({
- queryKey: ['userTweets', username],
- queryFn: ({ pageParam }) => getUserTweets(username, pageParam),
+ queryKey: queryKeys.user.tweets(username),
+ queryFn: async ({ pageParam }) => {
+ const result = await getUserTweets(username, pageParam);
+
+ // Populate the tweet cache with fetched tweets
+ if (result.data && Array.isArray(result.data)) {
+ tweetCache.setTweets(result.data);
+ }
+
+ return result;
+ },
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => {
return lastPage.pagination.hasNextPage ? lastPage.pagination.nextCursor : undefined;
diff --git a/src/hooks/tweets/useTweetDetail.ts b/src/hooks/tweets/useTweetDetail.ts
new file mode 100644
index 000000000..cc76fb5e1
--- /dev/null
+++ b/src/hooks/tweets/useTweetDetail.ts
@@ -0,0 +1,50 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
+import { getTweet } from '@/services/tweets';
+
+import type { Tweet } from '@/types/tweet';
+
+export function useTweetDetail(tweetId: string) {
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
+
+ return useQuery({
+ queryKey: queryKeys.tweet(tweetId),
+ queryFn: async () => {
+ const response = await getTweet(tweetId);
+
+ if (response.success && response.data) {
+ const tweetData = response.data;
+
+ // Cache the main tweet
+ tweetCache.setTweet(tweetData);
+
+ // Cache root tweet if it exists (only if not deleted)
+ if (tweetData.rootTweet && !('isDeleted' in tweetData.rootTweet)) {
+ tweetCache.setTweet(tweetData.rootTweet);
+ }
+
+ // Cache parent tweets (only non-deleted ones)
+ if (tweetData.parentTweets) {
+ const parentTweetsToCache = tweetData.parentTweets.filter(
+ (tweet): tweet is Tweet => !('isDeleted' in tweet)
+ );
+ tweetCache.setTweets(parentTweetsToCache);
+ }
+
+ return tweetData;
+ }
+
+ throw new Error('Failed to fetch tweet');
+ },
+ retry: (failureCount, error) => {
+ if (error instanceof Error && 'status' in error && error.status === 404) {
+ return false;
+ }
+ return failureCount < 2;
+ },
+ staleTime: 0, // Always refetch tweet detail when navigating
+ });
+}
diff --git a/src/hooks/tweets/useTweetInteractions.ts b/src/hooks/tweets/useTweetInteractions.ts
new file mode 100644
index 000000000..b3f31870d
--- /dev/null
+++ b/src/hooks/tweets/useTweetInteractions.ts
@@ -0,0 +1,69 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
+import { likeTweet, retweetTweet, unlikeTweet, unretweetTweet } from '@/services/tweets';
+
+export function useLikeTweetMutation() {
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
+
+ return useMutation({
+ mutationFn: async ({ tweetId, like }: { tweetId: string; like: boolean }) => {
+ return like ? await likeTweet(tweetId) : await unlikeTweet(tweetId);
+ },
+ onMutate: async ({ tweetId, like }) => {
+ await queryClient.cancelQueries({ queryKey: queryKeys.tweet(tweetId) });
+
+ const previousTweet = tweetCache.getTweet(tweetId);
+
+ tweetCache.updateLike(tweetId, like);
+
+ return { previousTweet, tweetId };
+ },
+ onError: (_error, _variables, context) => {
+ // Rollback to previous state
+ if (context?.previousTweet) {
+ tweetCache.setTweet(context.previousTweet);
+ }
+ },
+ onSuccess: (_data, { tweetId }) => {
+ // Invalidate likers list in background
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.tweetLikers(tweetId),
+ refetchType: 'none', // Don't refetch immediately
+ });
+ },
+ });
+}
+
+export function useRetweetTweetMutation() {
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
+
+ return useMutation({
+ mutationFn: async ({ tweetId, retweet }: { tweetId: string; retweet: boolean }) => {
+ return retweet ? await retweetTweet(tweetId) : await unretweetTweet(tweetId);
+ },
+ onMutate: async ({ tweetId, retweet }) => {
+ await queryClient.cancelQueries({ queryKey: queryKeys.tweet(tweetId) });
+
+ const previousTweet = tweetCache.getTweet(tweetId);
+
+ tweetCache.updateRetweet(tweetId, retweet);
+
+ return { previousTweet, tweetId };
+ },
+ onError: (_error, _variables, context) => {
+ if (context?.previousTweet) {
+ tweetCache.setTweet(context.previousTweet);
+ }
+ },
+ onSuccess: (_data, { tweetId }) => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.tweetRetweeters(tweetId),
+ refetchType: 'none',
+ });
+ },
+ });
+}
diff --git a/src/hooks/tweets/useTweetLikers.ts b/src/hooks/tweets/useTweetLikers.ts
index d7187d47b..74233f6ee 100644
--- a/src/hooks/tweets/useTweetLikers.ts
+++ b/src/hooks/tweets/useTweetLikers.ts
@@ -1,10 +1,11 @@
import { useInfiniteQuery } from '@tanstack/react-query';
+import { queryKeys } from '@/libs/queryKeys';
import { getTweetLikes } from '@/services/tweets';
export const useTweetLikers = (tweetId: string) => {
return useInfiniteQuery({
- queryKey: ['tweetLikers', tweetId],
+ queryKey: queryKeys.tweetLikers(tweetId),
queryFn: ({ pageParam }) =>
getTweetLikes(tweetId, pageParam ? { cursor: pageParam } : undefined),
initialPageParam: undefined as string | undefined,
diff --git a/src/hooks/tweets/useTweetQuotes.ts b/src/hooks/tweets/useTweetQuotes.ts
index 4abc67f27..7f1f88863 100644
--- a/src/hooks/tweets/useTweetQuotes.ts
+++ b/src/hooks/tweets/useTweetQuotes.ts
@@ -1,12 +1,25 @@
-import { useInfiniteQuery } from '@tanstack/react-query';
+import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
import { getTweetQuotes } from '@/services/tweets';
export const useTweetQuotes = (tweetId: string) => {
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
+
return useInfiniteQuery({
- queryKey: ['tweetQuotes', tweetId],
- queryFn: ({ pageParam }) =>
- getTweetQuotes(tweetId, pageParam ? { cursor: pageParam } : undefined),
+ queryKey: queryKeys.tweetQuotes(tweetId),
+ queryFn: async ({ pageParam }) => {
+ const result = await getTweetQuotes(tweetId, pageParam ? { cursor: pageParam } : undefined);
+
+ // Populate the tweet cache with fetched quotes
+ if (result.data && Array.isArray(result.data)) {
+ tweetCache.setTweets(result.data);
+ }
+
+ return result;
+ },
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => {
return lastPage.pagination.hasNextPage ? lastPage.pagination.nextCursor : undefined;
diff --git a/src/hooks/tweets/useTweetReplies.ts b/src/hooks/tweets/useTweetReplies.ts
new file mode 100644
index 000000000..d4f48501c
--- /dev/null
+++ b/src/hooks/tweets/useTweetReplies.ts
@@ -0,0 +1,31 @@
+import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
+
+import { queryKeys } from '@/libs/queryKeys';
+import { getTweetCache } from '@/libs/tweetCache';
+import { getTweetReplies } from '@/services/tweets';
+
+export function useTweetReplies(tweetId: string) {
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
+
+ return useInfiniteQuery({
+ queryKey: queryKeys.tweetReplies(tweetId),
+ queryFn: async ({ pageParam }) => {
+ const result = await getTweetReplies(tweetId, pageParam ? { cursor: pageParam } : undefined);
+
+ // Populate the tweet cache with fetched replies
+ if (result.data && Array.isArray(result.data)) {
+ tweetCache.setTweets(result.data);
+ }
+
+ return result;
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) => {
+ return lastPage.pagination.hasNextPage ? lastPage.pagination.nextCursor : undefined;
+ },
+ retry: false,
+ enabled: !!tweetId,
+ staleTime: 0, // Always refetch replies when navigating to tweet detail
+ });
+}
diff --git a/src/hooks/tweets/useTweetRetweeters.ts b/src/hooks/tweets/useTweetRetweeters.ts
index 118d57bcb..cd7f90ccb 100644
--- a/src/hooks/tweets/useTweetRetweeters.ts
+++ b/src/hooks/tweets/useTweetRetweeters.ts
@@ -1,10 +1,11 @@
import { useInfiniteQuery } from '@tanstack/react-query';
+import { queryKeys } from '@/libs/queryKeys';
import { getTweetRetweeters } from '@/services/tweets';
export const useTweetRetweeters = (tweetId: string) => {
return useInfiniteQuery({
- queryKey: ['tweetRetweeters', tweetId],
+ queryKey: queryKeys.tweetRetweeters(tweetId),
queryFn: ({ pageParam }) =>
getTweetRetweeters(tweetId, pageParam ? { cursor: pageParam } : undefined),
initialPageParam: undefined as string | undefined,
diff --git a/src/hooks/useChatSocket.ts b/src/hooks/useChatSocket.ts
index 09e396e52..62d538fc9 100644
--- a/src/hooks/useChatSocket.ts
+++ b/src/hooks/useChatSocket.ts
@@ -3,8 +3,9 @@ import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
+import { navigationRef } from '@/navigation/navigationRef';
import { dmKeys } from '@/services/dm';
-import { initSocket, joinConversation, leaveConversation } from '@/services/socket';
+import { initSocket } from '@/services/socket';
import { useSessionStore } from '@/stores/sessionStore';
import { useUserStore } from '@/stores/userStore';
@@ -23,6 +24,8 @@ type UseChatSocketParams = {
avatarUrl: string | null;
} | null;
onUserTyping?: (isTyping: boolean) => void;
+ onBlocked?: (blocked: boolean) => void;
+ onSendMessageFailed?: (clientMessageId?: string) => void;
};
export const useChatSocket = ({
@@ -34,6 +37,8 @@ export const useChatSocket = ({
initiallyMarkedOwnMessageIdRef,
participant,
onUserTyping,
+ onBlocked,
+ onSendMessageFailed,
}: UseChatSocketParams) => {
const queryClient = useQueryClient();
const currentUser = useUserStore((state) => state.user);
@@ -45,13 +50,10 @@ export const useChatSocket = ({
const socket = initSocket(accessToken);
if (!socket) return;
- const joinActiveConversation = () => joinConversation(conversationId);
-
const handleConversationSeenUpdate = (
payload: Parameters[0]
) => {
if (payload.conversationId !== conversationId) return;
-
if (
initiallyMarkedOwnMessageIdRef.current === payload.lastSeenMessageId &&
payload.username === currentUser.username
@@ -69,6 +71,7 @@ export const useChatSocket = ({
} else {
setLastSeenMessageId(payload.lastSeenMessageId);
}
+ onBlocked?.(false);
}
};
@@ -214,7 +217,6 @@ export const useChatSocket = ({
});
};
- if (socket.connected) joinActiveConversation();
socket.on('message_received', handleMessageReceived);
socket.on('conversation_seen_update', handleConversationSeenUpdate);
socket.on('reaction_received', handleReactionReceived);
@@ -236,13 +238,81 @@ export const useChatSocket = ({
socket.on('user_typing', handleUserTyping);
socket.on('user_typing_stop', handleUserTypingStop);
+ const handleError = (payload: Parameters[0]) => {
+ const { code, message, clientMessageId } = payload;
+
+ switch (code) {
+ case 'BLOCKED_USER':
+ onBlocked?.(true);
+ break;
+
+ case 'INVALID_CONVERSATION_ID':
+ case 'FORBIDDEN_CONVERSATION_ID':
+ case 'ASSERT_PARTICIPANT_FAILED':
+ console.error(`Conversation error [${code}]:`, message);
+ navigationRef.goBack();
+ break;
+
+ case 'INVALID_MESSAGE_ID':
+ case 'UPDATE_LAST_SEEN_FAILED':
+ console.error(`[${code}]:`, message);
+ break;
+
+ case 'MESSAGE_CREATION_FAILED':
+ case 'INVALID_MEDIA':
+ console.error(`Send failed [${code}]:`, message);
+ if (clientMessageId) {
+ queryClient.setQueryData(dmKeys.messages(conversationId), (old: any) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page: any, index: number) => {
+ if (index === 0) {
+ const messages: Message[] = page.data.messages;
+ const msgIdx = messages.findIndex((m) => m.id === clientMessageId);
+ if (msgIdx !== -1) {
+ const updatedMessages = [...messages];
+ updatedMessages[msgIdx] = { ...updatedMessages[msgIdx], failed: true };
+ return {
+ ...page,
+ data: { ...page.data, messages: updatedMessages },
+ };
+ }
+ }
+ return page;
+ }),
+ };
+ });
+ }
+ onSendMessageFailed?.(clientMessageId);
+ break;
+
+ case 'REACTION_CREATION_FAILED':
+ console.error('Reaction failed:', message);
+ break;
+
+ case 'MISSING_TOKEN':
+ case 'INVALID_TOKEN':
+ case 'SESSION_EXPIRED':
+ console.error('Session expired, signing out...');
+ useSessionStore.getState().clearActiveSession();
+ break;
+
+ default:
+ console.error('Unknown socket error:', code, message);
+ break;
+ }
+ };
+
+ socket.on('error', handleError);
+
return () => {
- leaveConversation(conversationId);
socket.off('message_received', handleMessageReceived);
socket.off('conversation_seen_update', handleConversationSeenUpdate);
socket.off('reaction_received', handleReactionReceived);
socket.off('user_typing', handleUserTyping);
socket.off('user_typing_stop', handleUserTypingStop);
+ socket.off('error', handleError);
};
}, [
conversationId,
@@ -257,5 +327,7 @@ export const useChatSocket = ({
initiallyMarkedOwnMessageIdRef,
participant,
onUserTyping,
+ onBlocked,
+ onSendMessageFailed,
]);
};
diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts
index c57a69b68..400ec193d 100644
--- a/src/hooks/useFeed.ts
+++ b/src/hooks/useFeed.ts
@@ -1,9 +1,12 @@
import {
useInfiniteQuery,
+ useQueryClient,
type InfiniteData,
type UseInfiniteQueryResult,
} from '@tanstack/react-query';
+import { ApiException } from '@/libs/api';
+import { getTweetCache } from '@/libs/tweetCache';
import { useUserStore } from '@/stores/userStore';
import type { TimelineFeedResponse } from '@/services/timeline';
@@ -21,17 +24,41 @@ export type TimelineFeedQueryResult = UseInfiniteQueryResult<
>;
interface UseFeedParams {
- cacheKey: string;
+ queryKey:
+ | readonly ['timeline', 'for-you', string]
+ | readonly ['timeline', 'following', string]
+ | readonly [string, string];
fetcher: TimelineFeedFetcher;
}
-export const useFeed = ({ cacheKey, fetcher }: UseFeedParams): TimelineFeedQueryResult => {
+export const useFeed = ({ queryKey, fetcher }: UseFeedParams): TimelineFeedQueryResult => {
const username = useUserStore((state) => state.user.username);
+ const queryClient = useQueryClient();
+ const tweetCache = getTweetCache(queryClient);
return useInfiniteQuery({
- queryKey: [cacheKey, username],
- queryFn: async ({ pageParam }) =>
- fetcher({ cursor: (pageParam as string | null) ?? null, limit: PAGE_SIZE }),
+ queryKey,
+ queryFn: async ({ pageParam }) => {
+ try {
+ const result = await fetcher({
+ cursor: (pageParam as string | null) ?? null,
+ limit: PAGE_SIZE,
+ });
+
+ if (result.data && Array.isArray(result.data)) tweetCache.setTweets(result.data);
+
+ return result;
+ } catch (error) {
+ if (error instanceof ApiException && error.status === 410) {
+ const result = await fetcher({ cursor: null, limit: PAGE_SIZE });
+
+ if (result.data && Array.isArray(result.data)) tweetCache.setTweets(result.data);
+
+ return result;
+ }
+ throw error;
+ }
+ },
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
enabled: !!username,
diff --git a/src/hooks/useMediaLibrary.ts b/src/hooks/useMediaLibrary.ts
index 79e8a0894..e7ea6a9d6 100644
--- a/src/hooks/useMediaLibrary.ts
+++ b/src/hooks/useMediaLibrary.ts
@@ -4,6 +4,10 @@ import { Platform } from 'react-native';
import * as MediaLibrary from 'expo-media-library';
+const hasMediaPermission = (response: MediaLibrary.PermissionResponse | null): boolean => {
+ return response?.granted ?? false;
+};
+
export function useMediaLibrary() {
const [albums, setAlbums] = useState(null);
const [assets, setAssets] = useState([]);
@@ -11,6 +15,7 @@ export function useMediaLibrary() {
const [hasNextPage, setHasNextPage] = useState(false);
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions();
const loadingRef = useRef(false);
+ const hasRequestedPermission = useRef(false);
const fetchAlbums = useCallback(
async (options: MediaLibrary.AlbumsOptions = { includeSmartAlbums: true }) => {
@@ -21,13 +26,19 @@ export function useMediaLibrary() {
try {
let response = permissionResponse;
- if (!response || response.status !== 'granted') response = await requestPermission();
+ // Only request permission if we haven't already and user can be asked
+ if (!hasMediaPermission(response) && !hasRequestedPermission.current) {
+ if (!response || response.canAskAgain !== false) {
+ hasRequestedPermission.current = true;
+ response = await requestPermission();
+ }
+ }
- if (response?.granted) {
+ if (hasMediaPermission(response)) {
const fetchedAlbums = await MediaLibrary.getAlbumsAsync(options);
setAlbums(fetchedAlbums);
} else {
- alert('Permission denied');
+ console.warn('Media library permission not granted');
}
} catch (error) {
console.error('Error fetching albums:', error);
@@ -46,11 +57,15 @@ export function useMediaLibrary() {
setLoading(true);
try {
let response = permissionResponse;
- if (!response || response.status !== 'granted') {
- response = await requestPermission();
+ // Only request permission if we haven't already and user can be asked
+ if (!hasMediaPermission(response) && !hasRequestedPermission.current) {
+ if (!response || response.canAskAgain !== false) {
+ hasRequestedPermission.current = true;
+ response = await requestPermission();
+ }
}
- if (response?.granted) {
+ if (hasMediaPermission(response)) {
const result = await MediaLibrary.getAssetsAsync(options);
// On iOS, convert ph:// URIs to file:// URIs for React Native Image component
diff --git a/src/hooks/useSSE.ts b/src/hooks/useSSE.ts
index 272f1d1ad..a3cd155a8 100644
--- a/src/hooks/useSSE.ts
+++ b/src/hooks/useSSE.ts
@@ -16,7 +16,6 @@ type AnySSEEventHandler = SSEEventHandler {
topics: string[];
- enabled: boolean;
eventHandlers: AnySSEEventHandler[];
pollingInterval?: number;
debug?: boolean;
@@ -30,7 +29,6 @@ interface SSEState {
export function useSSE({
topics,
- enabled,
eventHandlers,
pollingInterval = 5000,
debug = false,
@@ -44,54 +42,30 @@ export function useSSE({
});
useEffect(() => {
- if (!enabled || !token || topics.length === 0) {
- // Clean up if disabled
- if (sseStateRef.current.source) {
- try {
- sseStateRef.current.source.removeAllEventListeners();
- sseStateRef.current.source.close();
- } catch (error) {
- if (debug) console.warn('[SSE] Error closing connection:', error);
- }
- sseStateRef.current.source = null;
- sseStateRef.current.token = null;
- sseStateRef.current.initialized = false;
- setIsConnected(false);
- }
+ if (!token || topics.length === 0) {
return;
}
- if (!sseStateRef.current.source || sseStateRef.current.token !== token) {
- if (sseStateRef.current.source) {
- try {
- sseStateRef.current.source.removeAllEventListeners();
- sseStateRef.current.source.close();
- } catch (error) {
- if (debug) console.warn('[SSE] Error closing old connection:', error);
- }
- }
+ const topicsQuery = topics.join(',');
+ const url = `${baseURL}/stream?topics=${topicsQuery}`;
- const topicsQuery = topics.join(',');
- const url = `${baseURL}/stream?topics=${topicsQuery}`;
-
- try {
- sseStateRef.current.source = new EventSource(url, {
- headers: { Authorization: `Bearer ${token}` },
- method: 'GET',
- pollingInterval,
- debug,
- });
- setIsConnected(true);
- } catch (error) {
- console.warn('[SSE] Failed to initialize connection:', error);
- sseStateRef.current.source = null;
- setIsConnected(false);
- }
-
- sseStateRef.current.token = sseStateRef.current.source ? token : null;
- sseStateRef.current.initialized = false;
+ try {
+ sseStateRef.current.source = new EventSource(url, {
+ headers: { Authorization: `Bearer ${token}` },
+ method: 'GET',
+ pollingInterval,
+ debug,
+ });
+ setIsConnected(true);
+ } catch (error) {
+ console.warn('[SSE] Failed to initialize connection:', error);
+ sseStateRef.current.source = null;
+ setIsConnected(false);
}
+ sseStateRef.current.token = sseStateRef.current.source ? token : null;
+ sseStateRef.current.initialized = false;
+
if (sseStateRef.current.source && !sseStateRef.current.initialized) {
const source = sseStateRef.current.source;
@@ -130,7 +104,7 @@ export function useSSE({
};
setIsConnected(false);
};
- }, [enabled, token, topics, eventHandlers, pollingInterval, debug]);
+ }, [token, topics, eventHandlers, pollingInterval, debug]);
return {
isConnected,
diff --git a/src/libs/queryKeys.ts b/src/libs/queryKeys.ts
new file mode 100644
index 000000000..3e9138f67
--- /dev/null
+++ b/src/libs/queryKeys.ts
@@ -0,0 +1,40 @@
+import type { SearchPeopleFilter, SearchTab } from '@/types/search';
+
+export const queryKeys = {
+ tweet: (tweetId: string) => ['tweet', tweetId] as const,
+
+ tweetReplies: (tweetId: string, cursor?: string) =>
+ cursor
+ ? (['tweet', tweetId, 'replies', cursor] as const)
+ : (['tweet', tweetId, 'replies'] as const),
+ tweetQuotes: (tweetId: string) => ['tweet', tweetId, 'quotes'] as const,
+ tweetLikers: (tweetId: string) => ['tweetLikers', tweetId] as const,
+ tweetRetweeters: (tweetId: string) => ['tweetRetweeters', tweetId] as const,
+
+ timeline: {
+ forYou: (username: string) => ['timeline', 'for-you', username] as const,
+ following: (username: string) => ['timeline', 'following', username] as const,
+ },
+
+ // User feeds
+ user: {
+ tweets: (username: string) => ['user', username, 'tweets'] as const,
+ replies: (username: string) => ['user', username, 'replies'] as const,
+ likes: (username: string) => ['user', username, 'likes'] as const,
+ media: (username: string) => ['user', username, 'media'] as const,
+ },
+
+ search: {
+ tweets: (
+ query: string,
+ tab: SearchTab,
+ peopleFilter: SearchPeopleFilter,
+ excludeMutedAndBlocked: boolean
+ ) => ['search', 'tweets', query, tab, peopleFilter, excludeMutedAndBlocked] as const,
+ },
+
+ notifications: (username: string, type?: string) =>
+ type ? (['notifications', username, type] as const) : (['notifications', username] as const),
+
+ profile: (username: string) => ['profile', username] as const,
+} as const;
diff --git a/src/libs/tweetCache.ts b/src/libs/tweetCache.ts
new file mode 100644
index 000000000..43ff5f402
--- /dev/null
+++ b/src/libs/tweetCache.ts
@@ -0,0 +1,107 @@
+import { QueryClient } from '@tanstack/react-query';
+
+import { queryKeys } from './queryKeys';
+
+import type { Tweet } from '@/types/tweet';
+
+export class TweetCacheManager {
+ constructor(private queryClient: QueryClient) {}
+
+ getTweet(tweetId: string): Tweet | undefined {
+ return this.queryClient.getQueryData(queryKeys.tweet(tweetId));
+ }
+
+ setTweet(tweet: Tweet): void {
+ this.queryClient.setQueryData(queryKeys.tweet(tweet.id), tweet);
+ }
+
+ updateTweet(tweetId: string, updates: Partial): void {
+ const existing = this.getTweet(tweetId);
+ if (existing) {
+ this.setTweet({ ...existing, ...updates });
+ }
+ }
+
+ updateLike(tweetId: string, isLiked: boolean): void {
+ const tweet = this.getTweet(tweetId);
+ if (!tweet) return;
+
+ const newLikeCount = isLiked ? tweet.likeCount + 1 : Math.max(0, tweet.likeCount - 1);
+
+ this.updateTweet(tweetId, {
+ isLiked,
+ likeCount: newLikeCount,
+ });
+ }
+
+ updateRetweet(tweetId: string, isRetweeted: boolean): void {
+ const tweet = this.getTweet(tweetId);
+ if (!tweet) return;
+
+ const newRetweetCount = isRetweeted
+ ? tweet.retweetCount + 1
+ : Math.max(0, tweet.retweetCount - 1);
+
+ this.updateTweet(tweetId, {
+ isRetweeted,
+ retweetCount: newRetweetCount,
+ });
+ }
+
+ incrementReplyCount(tweetId: string): void {
+ const tweet = this.getTweet(tweetId);
+ if (tweet) {
+ this.updateTweet(tweetId, {
+ replyCount: tweet.replyCount + 1,
+ });
+ }
+ }
+
+ setTweets(tweets: Tweet[]): void {
+ tweets.forEach((tweet) => {
+ this.queryClient.setQueryData(queryKeys.tweet(tweet.id), tweet);
+ });
+ }
+
+ hasTweet(tweetId: string): boolean {
+ return this.getTweet(tweetId) !== undefined;
+ }
+
+ removeTweet(tweetId: string): void {
+ this.queryClient.removeQueries({ queryKey: queryKeys.tweet(tweetId) });
+ this.queryClient.removeQueries({ queryKey: queryKeys.tweetReplies(tweetId) });
+ this.queryClient.removeQueries({ queryKey: queryKeys.tweetQuotes(tweetId) });
+ this.queryClient.removeQueries({ queryKey: queryKeys.tweetLikers(tweetId) });
+ this.queryClient.removeQueries({ queryKey: queryKeys.tweetRetweeters(tweetId) });
+ }
+
+ async prefetchTweet(tweetId: string, fetcher: () => Promise): Promise {
+ await this.queryClient.prefetchQuery({
+ queryKey: queryKeys.tweet(tweetId),
+ queryFn: fetcher,
+ staleTime: 1000 * 60 * 5,
+ });
+ }
+
+ invalidateAllTweetQueries(): void {
+ this.queryClient.resetQueries({
+ predicate: (query) => {
+ const key = query.queryKey[0];
+ return key === 'timeline' || key === 'user' || key === 'search' || key === 'notifications';
+ },
+ });
+ }
+}
+
+let tweetCacheInstance: TweetCacheManager | null = null;
+
+export function getTweetCache(queryClient: QueryClient): TweetCacheManager {
+ if (!tweetCacheInstance || tweetCacheInstance['queryClient'] !== queryClient) {
+ tweetCacheInstance = new TweetCacheManager(queryClient);
+ }
+ return tweetCacheInstance;
+}
+
+export function resetTweetCache(): void {
+ tweetCacheInstance = null;
+}
diff --git a/src/mocks/data/explore.ts b/src/mocks/data/explore.ts
index 42fae077d..c7fa613c9 100644
--- a/src/mocks/data/explore.ts
+++ b/src/mocks/data/explore.ts
@@ -7,7 +7,7 @@ import { generateTweets } from './tweets';
const generateTrends = (length: number, categories: string[]): Trend[] => {
return Array.from({ length }, () => ({
hashtag: faker.helpers.arrayElement(['#ReactNative', '#programming', '#relax', '#food']),
- tweetCount: faker.number.int({ min: 100, max: 5000000 }),
+ tweetsCount: faker.number.int({ min: 100, max: 5000000 }),
category: faker.helpers.arrayElement(categories),
}));
};
diff --git a/src/mocks/data/search.ts b/src/mocks/data/search.ts
index eb72b8399..f5cf3b1ae 100644
--- a/src/mocks/data/search.ts
+++ b/src/mocks/data/search.ts
@@ -111,6 +111,25 @@ export function getTopHashtags(query: string) {
return TRENDING_HASHTAGS.filter((item) => item.hashtag.includes(queryTrim)).slice(0, 3);
}
+export function getSearchSuggestions(query: string): string[] {
+ const q = (query || '').trim().toLowerCase();
+ if (!q) return [];
+
+ const suggestions: string[] = [];
+
+ const matchingHashtags = TRENDING_HASHTAGS.filter((item) =>
+ item.hashtag.toLowerCase().includes(q.replace('#', ''))
+ )
+ .slice(0, 3)
+ .map((item) => `#${item.hashtag}`);
+ suggestions.push(...matchingHashtags);
+
+ const topicSuggestions = [`${q} 1`, `${q} 2`, `${q} 3`];
+ suggestions.push(...topicSuggestions);
+
+ return suggestions.slice(0, 5);
+}
+
type SearchTweetsMockParams = {
query: string;
tab: 'top' | 'latest' | 'media';
diff --git a/src/mocks/data/tweets.ts b/src/mocks/data/tweets.ts
index 5bfaac9e2..1561d2a2d 100644
--- a/src/mocks/data/tweets.ts
+++ b/src/mocks/data/tweets.ts
@@ -56,8 +56,13 @@ export const generateTweets = (count: number): Tweet[] => {
username: faker.internet.username(),
displayName: faker.person.fullName(),
avatarUrl: null,
- isFollower: faker.datatype.boolean(),
- isFollowing: faker.datatype.boolean(),
+ relationship: {
+ follower: faker.datatype.boolean(),
+ following: faker.datatype.boolean(),
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
};
const content = faker.helpers.arrayElement(fixedTweetContents);
const createdAt = faker.date.recent({ days: 7 }).toISOString();
@@ -72,6 +77,13 @@ export const generateTweets = (count: number): Tweet[] => {
username: faker.internet.username(),
displayName: faker.person.fullName(),
avatarUrl: null,
+ relationship: {
+ following: faker.datatype.boolean(),
+ follower: faker.datatype.boolean(),
+ muted: faker.datatype.boolean(),
+ blocking: faker.datatype.boolean(),
+ blockedBy: faker.datatype.boolean(),
+ },
}
: null;
const entities = parseEntities(content);
@@ -96,8 +108,13 @@ export const generateTweets = (count: number): Tweet[] => {
username: faker.internet.username(),
displayName: faker.person.fullName(),
avatarUrl: null,
- isFollower: faker.datatype.boolean(),
- isFollowing: faker.datatype.boolean(),
+ relationship: {
+ follower: faker.datatype.boolean(),
+ following: faker.datatype.boolean(),
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: faker.lorem.sentence(),
createdAt: faker.date.recent({ days: 7 }).toISOString(),
@@ -175,8 +192,13 @@ demoTweets.unshift(
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
- isFollower: false,
- isFollowing: false,
+ relationship: {
+ follower: false,
+ following: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: '🧵 This is the root tweet that started an interesting discussion',
createdAt: new Date(Date.now() - 9 * 24 * 60 * 60 * 1000).toISOString(),
@@ -199,8 +221,13 @@ demoTweets.unshift(
username: 'alice',
displayName: 'Alice Johnson',
avatarUrl: 'https://example.com/avatar2.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Great point! Let me add to this discussion',
createdAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
@@ -223,8 +250,13 @@ demoTweets.unshift(
username: 'bob',
displayName: 'Bob Smith',
avatarUrl: 'https://example.com/avatar3.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'I completely agree with what was said above',
createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
@@ -255,8 +287,13 @@ demoTweets.unshift(
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
- isFollower: false,
- isFollowing: false,
+ relationship: {
+ follower: false,
+ following: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'This thread is getting really interesting now',
createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
@@ -287,8 +324,13 @@ demoTweets.unshift(
username: 'alice',
displayName: 'Alice Johnson',
avatarUrl: 'https://example.com/avatar2.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Building on the previous points made here',
createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
@@ -311,8 +353,13 @@ demoTweets.unshift(
username: 'bob',
displayName: 'Bob Smith',
avatarUrl: 'https://example.com/avatar3.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Adding another layer to this conversation',
createdAt: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(),
@@ -343,8 +390,13 @@ demoTweets.unshift(
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
- isFollower: false,
- isFollowing: false,
+ relationship: {
+ follower: false,
+ following: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'This is getting deep! Great discussion everyone',
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
@@ -370,8 +422,13 @@ demoTweets.unshift({
username: 'bob',
displayName: 'Bob Smith',
avatarUrl: 'https://example.com/avatar3.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'This is a direct reply deep in the thread',
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
@@ -404,8 +461,13 @@ demoTweets.unshift({
username: 'alice',
displayName: 'Alice Johnson',
avatarUrl: 'https://example.com/avatar2.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'And I am replying to reply-1!',
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
@@ -439,8 +501,13 @@ demoTweets.unshift(
username: 'alice',
displayName: 'Alice Johnson',
avatarUrl: 'https://example.com/avatar2.jpg',
- isFollower: true,
- isFollowing: true,
+ relationship: {
+ follower: true,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: '🔥 Hot take: TypeScript is better than JavaScript',
createdAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
@@ -463,8 +530,13 @@ demoTweets.unshift(
username: 'bob',
displayName: 'Bob Smith',
avatarUrl: 'https://example.com/avatar3.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Totally agree! Type safety is a game changer',
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
@@ -498,8 +570,13 @@ demoTweets.unshift({
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
- isFollower: true,
- isFollowing: false,
+ relationship: {
+ follower: true,
+ following: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Facts! No more runtime errors 🎉',
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
@@ -533,8 +610,13 @@ demoTweets.unshift(
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
- isFollower: false,
- isFollowing: false,
+ relationship: {
+ follower: false,
+ following: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: '💡 Just discovered a cool React pattern',
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
@@ -557,8 +639,13 @@ demoTweets.unshift(
username: 'alice',
displayName: 'Alice Johnson',
avatarUrl: 'https://example.com/avatar2.jpg',
- isFollower: true,
- isFollowing: true,
+ relationship: {
+ follower: true,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Tell me more! What pattern?',
createdAt: new Date(Date.now() - 1.8 * 24 * 60 * 60 * 1000).toISOString(),
@@ -581,8 +668,13 @@ demoTweets.unshift(
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
- isFollower: false,
- isFollowing: false,
+ relationship: {
+ follower: false,
+ following: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Using custom hooks with context to avoid prop drilling',
createdAt: new Date(Date.now() - 1.5 * 24 * 60 * 60 * 1000).toISOString(),
@@ -605,8 +697,13 @@ demoTweets.unshift(
username: 'bob',
displayName: 'Bob Smith',
avatarUrl: 'https://example.com/avatar3.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Oh nice! I use that pattern all the time',
createdAt: new Date(Date.now() - 1.2 * 24 * 60 * 60 * 1000).toISOString(),
@@ -632,8 +729,13 @@ demoTweets.unshift({
username: 'alice',
displayName: 'Alice Johnson',
avatarUrl: 'https://example.com/avatar2.jpg',
- isFollower: true,
- isFollowing: true,
+ relationship: {
+ follower: true,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Same here! Makes code so much cleaner',
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
@@ -666,8 +768,13 @@ demoTweets.push({
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
- isFollower: false,
- isFollowing: false,
+ relationship: {
+ follower: false,
+ following: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Check out this interesting tweet 👇',
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
@@ -693,8 +800,13 @@ demoTweets.unshift(
username: 'alice',
displayName: 'Alice Johnson',
avatarUrl: 'https://example.com/avatar2.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Replying to a deleted tweet',
createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
@@ -717,8 +829,13 @@ demoTweets.unshift(
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
- isFollower: false,
- isFollowing: false,
+ relationship: {
+ follower: false,
+ following: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'This thread has a deleted root tweet above 👆',
createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
@@ -745,8 +862,13 @@ demoTweets.unshift(
username: 'bob',
displayName: 'Bob Smith',
avatarUrl: 'https://example.com/avatar3.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Starting a long discussion',
createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
@@ -769,8 +891,13 @@ demoTweets.unshift(
username: 'alice',
displayName: 'Alice Johnson',
avatarUrl: 'https://example.com/avatar2.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Great point! Adding my thoughts',
createdAt: new Date(Date.now() - 9 * 24 * 60 * 60 * 1000).toISOString(),
@@ -793,8 +920,13 @@ demoTweets.unshift(
username: 'alice',
displayName: 'Alice Johnson',
avatarUrl: 'https://example.com/avatar2.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Continuing after the deleted tweet',
createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
@@ -817,8 +949,13 @@ demoTweets.unshift(
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
- isFollower: false,
- isFollowing: false,
+ relationship: {
+ follower: false,
+ following: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'This thread has a deleted tweet in the middle of the chain 🔗',
createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
@@ -845,8 +982,13 @@ demoTweets.unshift(
username: 'bob',
displayName: 'Bob Smith',
avatarUrl: 'https://example.com/avatar3.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'This is the start of a thread with multiple deletions',
createdAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(),
@@ -869,8 +1011,13 @@ demoTweets.unshift(
username: 'alice',
displayName: 'Alice Johnson',
avatarUrl: 'https://example.com/avatar2.jpg',
- isFollower: false,
- isFollowing: true,
+ relationship: {
+ follower: false,
+ following: true,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'Continuing the discussion',
createdAt: new Date(Date.now() - 12 * 24 * 60 * 60 * 1000).toISOString(),
@@ -893,8 +1040,13 @@ demoTweets.unshift(
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
- isFollower: false,
- isFollowing: false,
+ relationship: {
+ follower: false,
+ following: false,
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: 'This thread has TWO deleted tweets in the parent chain! 🚨',
createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
diff --git a/src/mocks/data/userMock.ts b/src/mocks/data/userMock.ts
index 7ee2675ab..02583a7ca 100644
--- a/src/mocks/data/userMock.ts
+++ b/src/mocks/data/userMock.ts
@@ -1,4 +1,4 @@
-import { CompactUser, FollowingUser, UpdateProfileRequest, UserProfile } from '@/types/user';
+import { CompactUser, FollowUserSuggestion, UpdateProfileRequest, UserProfile } from '@/types/user';
export const mockUsers: Record = {
// authenticated mock user, relationships are all false
@@ -127,7 +127,7 @@ export const mockUpdateProfile: UpdateProfileRequest = {
banner: { uri: 'https://placehold.co/600x400.jpg' },
};
-export const mockFollowingList: FollowingUser[] = [
+export const mockFollowingList: CompactUser[] = [
{
username: 'janedoe',
displayName: 'Jane Doe',
@@ -137,9 +137,6 @@ export const mockFollowingList: FollowingUser[] = [
hashtags: [{ hashtag: 'UIUX', startPosition: 25 }],
},
avatarUrl: 'https://randomuser.me/api/portraits/women/68.jpg',
- isFollowing: true,
- followsYou: false,
- isBlocked: false,
relationship: {
blocking: false,
blockedBy: false,
@@ -150,7 +147,7 @@ export const mockFollowingList: FollowingUser[] = [
},
];
-export const mockFollowersList: FollowingUser[] = [
+export const mockFollowersList: CompactUser[] = [
{
username: 'janedoe',
displayName: 'Jane Doe',
@@ -160,9 +157,6 @@ export const mockFollowersList: FollowingUser[] = [
hashtags: [{ hashtag: 'UIUX', startPosition: 25 }],
},
avatarUrl: 'https://randomuser.me/api/portraits/women/68.jpg',
- isFollowing: true,
- followsYou: true,
- isBlocked: false,
relationship: {
blocking: false,
blockedBy: false,
@@ -177,9 +171,6 @@ export const mockFollowersList: FollowingUser[] = [
bio: 'Documenting our cosmic journey',
avatarUrl: 'https://placehold.co/400',
bioEntities: null,
- isFollowing: true,
- followsYou: false,
- isBlocked: false,
relationship: {
blocking: false,
blockedBy: false,
@@ -190,7 +181,7 @@ export const mockFollowersList: FollowingUser[] = [
},
];
-export const mockMutualsList: FollowingUser[] = [
+export const mockMutualsList: CompactUser[] = [
{
username: 'janedoe',
displayName: 'Jane Doe',
@@ -200,9 +191,13 @@ export const mockMutualsList: FollowingUser[] = [
hashtags: [{ hashtag: 'UIUX', startPosition: 25 }],
},
avatarUrl: 'https://randomuser.me/api/portraits/women/68.jpg',
- isFollowing: true,
- followsYou: true,
- isBlocked: false,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: true,
+ following: true,
+ follower: true,
+ },
},
];
@@ -218,27 +213,53 @@ export const blockedMockedUsers: CompactUser[] = [
blockedBy: false,
muted: true,
following: false,
- follower: null,
+ follower: undefined,
},
},
];
export const mutedMockedUsers: CompactUser[] = [
{
- username: 'janedoe',
- displayName: 'Jane Doe',
- bio: 'Designer & coffee addict',
- bioEntities: {
- mentions: [{ username: 'designhub', startPosition: 9 }],
- hashtags: [{ hashtag: 'UIUX', startPosition: 25 }],
+ username: 'starlit_coder',
+ displayName: 'random_user',
+ bio: 'Exploring the universe of code',
+ avatarUrl: 'https://placehold.co/400',
+ bioEntities: null,
+ relationship: {
+ blocking: false,
+ blockedBy: false,
+ muted: true,
+ following: false,
+ follower: undefined,
},
- avatarUrl: 'https://randomuser.me/api/portraits/women/68.jpg',
+ },
+ {
+ username: 'astro_nerd',
+ displayName: 'Liam Carter',
+ bio: 'JS dev',
+ avatarUrl: 'https://placehold.co/400',
+ bioEntities: null,
relationship: {
blocking: false,
blockedBy: false,
muted: true,
following: true,
- follower: true,
+ follower: undefined,
},
},
];
+
+export const mockUserSuggestionsToFollow: FollowUserSuggestion[] = [
+ {
+ username: 'cosmic_explorer',
+ displayName: 'Ella Thompson',
+ bio: 'Journeying through the stars',
+ avatarUrl: 'https://placehold.co/400',
+ },
+ {
+ username: 'galaxy_gazer',
+ displayName: 'Noah Wilson',
+ bio: 'Stargazing and astrophotography',
+ avatarUrl: 'https://placehold.co/400',
+ },
+];
diff --git a/src/mocks/handlers/meHandlers.ts b/src/mocks/handlers/meHandlers.ts
index a6a2c5ddd..ce03fc9b6 100644
--- a/src/mocks/handlers/meHandlers.ts
+++ b/src/mocks/handlers/meHandlers.ts
@@ -47,8 +47,8 @@ export const meHandlers = [
blocking: true,
blockedBy: false,
muted: false,
- following: null,
- follower: null,
+ following: undefined,
+ follower: undefined,
},
followingCount: Math.floor(Math.random() * 1000),
followersCount: Math.floor(Math.random() * 1000),
@@ -152,8 +152,8 @@ export const meHandlers = [
blocking: false,
blockedBy: false,
muted: true,
- following: null,
- follower: null,
+ following: undefined,
+ follower: undefined,
},
followingCount: Math.floor(Math.random() * 1000),
followersCount: Math.floor(Math.random() * 1000),
diff --git a/src/mocks/handlers/notificationHandlers.ts b/src/mocks/handlers/notificationHandlers.ts
index 8bd4ecf5b..68d6a8ba7 100644
--- a/src/mocks/handlers/notificationHandlers.ts
+++ b/src/mocks/handlers/notificationHandlers.ts
@@ -146,8 +146,13 @@ function generateMockTweet(): Tweet {
username: faker.internet.username({ firstName, lastName }).toLowerCase(),
displayName: `${firstName} ${lastName}`,
avatarUrl: faker.image.avatar(),
- isFollowing: faker.datatype.boolean(),
- isFollower: faker.datatype.boolean(),
+ relationship: {
+ following: faker.datatype.boolean(),
+ follower: faker.datatype.boolean(),
+ blocking: false,
+ blockedBy: false,
+ muted: false,
+ },
},
content: content.trim(),
createdAt: faker.date.recent({ days: 7 }).toISOString(),
diff --git a/src/mocks/handlers/onBoardingHandlers.ts b/src/mocks/handlers/onBoardingHandlers.ts
index a26642798..8f9d38ddf 100644
--- a/src/mocks/handlers/onBoardingHandlers.ts
+++ b/src/mocks/handlers/onBoardingHandlers.ts
@@ -1,4 +1,7 @@
import { http, HttpResponse } from 'msw';
+
+import { mockUserSuggestionsToFollow } from '../data/userMock';
+
let baseURL = process.env.EXPO_PUBLIC_API_BASE_URL || 'https://mock.cmp27.space';
if (baseURL && baseURL.endsWith('/')) {
baseURL = baseURL.slice(0, -1);
@@ -13,4 +16,12 @@ export const onBoardingHandlers = [
: Array.from({ length: 5 }, (_, i) => `user_sugg${i + 1}`);
return HttpResponse.json({ success: true, data: { suggestions } });
}),
+ http.get(`${baseURL}/onboarding/follow-suggestions`, async () => {
+ const suggestions = mockUserSuggestionsToFollow;
+ return HttpResponse.json({
+ success: true,
+ data: { suggestions },
+ message: 'Follow suggestions fetched successfully.',
+ });
+ }),
];
diff --git a/src/mocks/handlers/profileHandlers.ts b/src/mocks/handlers/profileHandlers.ts
index 1f4855549..b13b81b28 100644
--- a/src/mocks/handlers/profileHandlers.ts
+++ b/src/mocks/handlers/profileHandlers.ts
@@ -290,7 +290,7 @@ export const profileHandlers = [
blockedBy: false,
muted: false,
following: true,
- follower: null,
+ follower: undefined,
},
followingCount: Math.floor(Math.random() * 1000),
followersCount: Math.floor(Math.random() * 1000),
@@ -300,7 +300,7 @@ export const profileHandlers = [
mockUsers[username] = user;
}
- mockFollowingList.push({ ...user, isFollowing: true, followsYou: false, isBlocked: false });
+ mockFollowingList.push({ ...user, relationship: { ...user.relationship, following: true } });
// update muted users list
mutedMockedUsers.map((user) =>
diff --git a/src/mocks/handlers/searchHandlers.ts b/src/mocks/handlers/searchHandlers.ts
index 26906384c..eb981456c 100644
--- a/src/mocks/handlers/searchHandlers.ts
+++ b/src/mocks/handlers/searchHandlers.ts
@@ -1,6 +1,6 @@
import { http, HttpResponse } from 'msw';
-import { getSuggestedUsers, getTopHashtags, searchTweetsMock } from '../data/search';
+import { getSearchSuggestions, getSuggestedUsers, searchTweetsMock } from '../data/search';
import type { SearchUser } from '@/types/search';
@@ -15,11 +15,13 @@ export const searchHandlers = [
const users = getSuggestedUsers(query);
const updatedUsers: SearchUser[] = users.map((user) => ({
...user,
+ bioEntities: null,
relationship: {
- follower: null,
- following: null,
+ follower: undefined,
+ following: undefined,
blocking: false,
blockedBy: false,
+ muted: false,
},
}));
@@ -38,17 +40,42 @@ export const searchHandlers = [
);
}),
- http.get(`${baseURL}/search/hashtags/top`, ({ request }) => {
+ http.get(`${baseURL}/search/suggestions`, ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get('query') || '';
- const hashtags = getTopHashtags(query);
+ const suggestions = getSearchSuggestions(query);
return HttpResponse.json(
{
success: true,
- message: 'Hashtags fetched successfully',
- data: { hashtags },
+ message: 'Suggestions fetched successfully',
+ data: suggestions,
+ },
+ { status: 200 }
+ );
+ }),
+
+ http.get(`${baseURL}/search/users/suggestions`, ({ request }) => {
+ const url = new URL(request.url);
+ const query = url.searchParams.get('query') || '';
+ const users = getSuggestedUsers(query);
+ const updatedUsers: SearchUser[] = users.map((user) => ({
+ ...user,
+ bioEntities: null,
+ relationship: {
+ follower: undefined,
+ following: undefined,
+ blocking: false,
+ blockedBy: false,
+ },
+ }));
+
+ return HttpResponse.json(
+ {
+ success: true,
+ message: 'User suggestions fetched successfully',
+ data: { users: updatedUsers },
},
{ status: 200 }
);
diff --git a/src/mocks/handlers/timelineHandlers.ts b/src/mocks/handlers/timelineHandlers.ts
index b023fc7fe..6b9e276b4 100644
--- a/src/mocks/handlers/timelineHandlers.ts
+++ b/src/mocks/handlers/timelineHandlers.ts
@@ -29,8 +29,13 @@ const TIMELINE_DATA = DATASET.map((tweet) => {
...tweet.quotedTweet,
author: {
...tweet.quotedTweet.author,
- isFollowing: tweet.quotedTweet.author.isFollowing,
- isFollower: tweet.quotedTweet.author.isFollower,
+ relationship: {
+ following: tweet.quotedTweet.author.relationship.following,
+ follower: tweet.quotedTweet.author.relationship.follower,
+ muted: tweet.quotedTweet.author.relationship.muted,
+ blocking: tweet.quotedTweet.author.relationship.blocking,
+ blockedBy: tweet.quotedTweet.author.relationship.blockedBy,
+ },
},
}
: null;
@@ -39,8 +44,13 @@ const TIMELINE_DATA = DATASET.map((tweet) => {
...tweet,
author: {
...tweet.author,
- isFollowing: tweet.author.isFollowing,
- isFollower: tweet.author.isFollower,
+ relationship: {
+ following: tweet.author.relationship.following,
+ follower: tweet.author.relationship.follower,
+ muted: tweet.author.relationship.muted,
+ blocking: tweet.author.relationship.blocking,
+ blockedBy: tweet.author.relationship.blockedBy,
+ },
},
quotedTweet,
};
diff --git a/src/mocks/handlers/tweetHandlers.ts b/src/mocks/handlers/tweetHandlers.ts
index f1a02062c..d4fc4ac25 100644
--- a/src/mocks/handlers/tweetHandlers.ts
+++ b/src/mocks/handlers/tweetHandlers.ts
@@ -79,8 +79,13 @@ const mockRetweeters = [
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
bio: 'Test bio',
- isFollowing: true,
- isFollower: false,
+ relationship: {
+ following: true,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
},
];
@@ -141,6 +146,13 @@ export const tweetHandlers = [
username: 'testuser',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.jpg',
+ relationship: {
+ following: false,
+ follower: false,
+ muted: false,
+ blocking: false,
+ blockedBy: false,
+ },
},
content: body.content,
createdAt: new Date().toISOString(),
diff --git a/src/mocks/handlers/userHandlers.ts b/src/mocks/handlers/userHandlers.ts
index 028b5cdad..8bd1e5822 100644
--- a/src/mocks/handlers/userHandlers.ts
+++ b/src/mocks/handlers/userHandlers.ts
@@ -27,8 +27,8 @@ export const userHandlers = [
blocking: false,
blockedBy: false,
muted: false,
- following: null,
- follower: null,
+ following: undefined,
+ follower: undefined,
},
followingCount: Math.floor(Math.random() * 1000),
followersCount: Math.floor(Math.random() * 1000),
@@ -91,4 +91,26 @@ export const userHandlers = [
{ status: 200 }
);
}),
+ http.post(`${baseURL}/users/:username/following`, async ({ params }) => {
+ const { username } = params as { username: string };
+
+ return HttpResponse.json(
+ {
+ success: true,
+ message: `Successfully followed user '${username}'.`,
+ },
+ { status: 200 }
+ );
+ }),
+ http.delete(`${baseURL}/users/:username/following`, async ({ params }) => {
+ const { username } = params as { username: string };
+
+ return HttpResponse.json(
+ {
+ success: true,
+ message: `Successfully unfollowed user '${username}'.`,
+ },
+ { status: 200 }
+ );
+ }),
];
diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx
index 5ad93eded..a658781c9 100644
--- a/src/navigation/RootNavigator.tsx
+++ b/src/navigation/RootNavigator.tsx
@@ -1,5 +1,7 @@
import { createStackNavigator } from '@react-navigation/stack';
+import { useTheme } from '@/hooks/useTheme';
+import { PrivacyPolicyScreen, TermsOfServiceScreen } from '@/screens/legal';
import ChatScreen from '@/screens/messages/ChatScreen';
import MessageImageScreen from '@/screens/messages/MessageImageScreen';
import PlaygroundScreen from '@/screens/playground/PlaygroundScreen';
@@ -7,6 +9,7 @@ import SearchFiltersScreen from '@/screens/search/SearchFiltersScreen';
import SearchScreen from '@/screens/search/SearchScreen';
import StartScreen from '@/screens/StartScreen';
import { RootStackParamList } from '@/types/navigation';
+import { colors } from '@/utils/colorTheme';
import { ROOT } from '@/utils/navigation/routeNames';
import { DrawerNavigator } from './app/DrawerNavigator';
@@ -23,6 +26,23 @@ type Props = {
};
const RootNavigator = ({ initialRouteName = 'Start' }: Props) => {
+ const { theme } = useTheme();
+
+ const headerOptions = {
+ headerShown: true,
+ headerBackTitleVisible: false,
+ headerBackTitle: ' ',
+ headerStyle: {
+ backgroundColor: colors[theme].background,
+ borderBottomColor: theme === 'light' ? colors[theme].border : colors[theme].mutedForeground,
+ borderBottomWidth: theme === 'light' ? 1 : 0.5,
+ },
+ headerTintColor: colors[theme].foreground,
+ headerTitleStyle: {
+ color: colors[theme].foreground,
+ },
+ };
+
return (
{
options={{ headerShown: false }}
/>
+
+
+
);
};
diff --git a/src/navigation/app/ActivityNavigator.tsx b/src/navigation/app/ActivityNavigator.tsx
index c8421b2aa..77e77fdf9 100644
--- a/src/navigation/app/ActivityNavigator.tsx
+++ b/src/navigation/app/ActivityNavigator.tsx
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
-import { Text, TouchableOpacity, View } from 'react-native';
+import { Dimensions, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import {
@@ -41,6 +41,10 @@ export function ActivityNavigator() {
tabBarIndicatorStyle: {
backgroundColor: colorscheme.primary,
borderColor: colorscheme.background,
+ marginHorizontal: 15,
+ height: 3,
+ borderRadius: 2,
+ width: Dimensions.get('window').width / 3.5,
},
};
}, [theme, topTabsConfig]);
diff --git a/src/navigation/app/CustomDrawerContent.tsx b/src/navigation/app/CustomDrawerContent.tsx
index b9074e398..271fbcbc3 100644
--- a/src/navigation/app/CustomDrawerContent.tsx
+++ b/src/navigation/app/CustomDrawerContent.tsx
@@ -153,7 +153,11 @@ export default function CustomDrawerContent(props: DrawerContentComponentProps)
-
+
{theme === 'dark' ? (
) : (
diff --git a/src/navigation/app/DrawerNavigator.tsx b/src/navigation/app/DrawerNavigator.tsx
index b52b13921..e7ba549a8 100644
--- a/src/navigation/app/DrawerNavigator.tsx
+++ b/src/navigation/app/DrawerNavigator.tsx
@@ -2,14 +2,13 @@ import { useMemo } from 'react';
import { Text, View } from 'react-native';
-import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
+import { Ionicons } from '@expo/vector-icons';
import { createDrawerNavigator } from '@react-navigation/drawer';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui';
import { useDrawerConfig } from '@/hooks/navigation/useDrawerConfig';
import { useTheme } from '@/hooks/useTheme';
-import FollowerRequestsScreen from '@/screens/drawer/FollowerRequestsScreen';
import PlaygroundScreen from '@/screens/playground/PlaygroundScreen';
import { DrawerParamList } from '@/types/navigation';
import { colors } from '@/utils/colorTheme';
@@ -71,42 +70,6 @@ export function DrawerNavigator() {
}}
/>
- (
-
- ),
- drawerLabel: 'Follower requests',
- headerShown: true,
- header: ({ navigation }) => {
- return (
-
-
- {
- navigation.closeDrawer();
- navigation.goBack();
- }}
- variant="ghost-default"
- size="xl"
- />
-
- Followers requests
-
-
-
- );
- },
- }}
- />
-
();
@@ -37,14 +46,37 @@ export function SearchResultsNavigator({
onTabChange,
}: SearchResultsNavigatorProps) {
const topTabsConfig = useTopTabsConfig();
+ const { theme } = useTheme();
const trimmedQuery = query.trim();
const initialRouteName = tabToRoute[initialTab];
+ const screenOptions: MaterialTopTabNavigationOptions = useMemo(() => {
+ const colorscheme = colors[theme];
+
+ return {
+ ...topTabsConfig,
+ tabBarItemStyle: {
+ width: undefined,
+ },
+ tabBarIndicatorStyle: {
+ backgroundColor: colorscheme.primary,
+ borderColor: colorscheme.background,
+ marginHorizontal: 10,
+ height: 3,
+ borderRadius: 2,
+ width: Dimensions.get('window').width / 5,
+ },
+ tabBarIndicatorContainerStyle: {
+ alignItems: 'center',
+ },
+ };
+ }, [theme, topTabsConfig]);
+
return (
{
const state = e.data.state;
diff --git a/src/navigation/app/bottomTabs/BottomTabsNavigator.tsx b/src/navigation/app/bottomTabs/BottomTabsNavigator.tsx
index 9aa469d05..a305f3f51 100644
--- a/src/navigation/app/bottomTabs/BottomTabsNavigator.tsx
+++ b/src/navigation/app/bottomTabs/BottomTabsNavigator.tsx
@@ -22,25 +22,27 @@ export function BottomTabsNavigator() {
component={HomeNavigator}
options={({ route }) => ({
headerShown: getFocusedRouteNameFromRoute(route) !== TWEET.DETAIL,
+ tabBarAccessibilityLabel: 'HomeTab',
})}
/>
({
tabBarButtonTestID: 'NotificationsTab',
+ tabBarAccessibilityLabel: 'NotificationsTab',
headerShown: getFocusedRouteNameFromRoute(route) !== TWEET.DETAIL,
})}
/>
);
diff --git a/src/navigation/app/bottomTabs/ExploreNavigator.tsx b/src/navigation/app/bottomTabs/ExploreNavigator.tsx
index bf8c238d8..504b6bb91 100644
--- a/src/navigation/app/bottomTabs/ExploreNavigator.tsx
+++ b/src/navigation/app/bottomTabs/ExploreNavigator.tsx
@@ -6,53 +6,77 @@ import {
} from '@react-navigation/material-top-tabs';
import { useTopTabsConfig } from '@/hooks/navigation/useTopTabsConfig';
+import { useTheme } from '@/hooks/useTheme';
import EntertainmentScreen from '@/screens/explore/EntertainmentScreen';
import ForYouExploreScreen from '@/screens/explore/ForYouExploreScreen';
import NewsScreen from '@/screens/explore/NewsScreen';
import SportsScreen from '@/screens/explore/SportsScreen';
import TrendingScreen from '@/screens/explore/TrendingScreen';
import { ExploreTabsParamList } from '@/types/navigation';
+import { colors } from '@/utils';
import { EXPLORE } from '@/utils/navigation/routeNames';
const Tab = createMaterialTopTabNavigator();
export function ExploreNavigator() {
+ const { theme } = useTheme();
const topTabsConfig = useTopTabsConfig();
const screenOptions: MaterialTopTabNavigationOptions = useMemo(() => {
+ const colorscheme = colors[theme];
+
return {
...topTabsConfig,
tabBarScrollEnabled: true,
- tabBarItemStyle: { width: 'auto' },
+ tabBarItemStyle: {
+ width: 'auto',
+ overflow: 'hidden',
+ },
+ tabBarIndicatorStyle: {
+ backgroundColor: colorscheme.primary,
+ borderColor: colorscheme.background,
+ height: 3,
+ width: 0.52,
+ },
+ tabBarIndicatorContainerStyle: {
+ alignItems: 'center',
+ },
};
- }, [topTabsConfig]);
+ }, [topTabsConfig, theme]);
return (
);
diff --git a/src/navigation/app/bottomTabs/HomeNavigator.tsx b/src/navigation/app/bottomTabs/HomeNavigator.tsx
index 2b4229620..2198cd8ab 100644
--- a/src/navigation/app/bottomTabs/HomeNavigator.tsx
+++ b/src/navigation/app/bottomTabs/HomeNavigator.tsx
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
-import { Pressable, StyleSheet, View } from 'react-native';
+import { Dimensions, Pressable, StyleSheet, View } from 'react-native';
import { AntDesign } from '@expo/vector-icons';
import {
@@ -42,15 +42,17 @@ function HomeTabs() {
tabBarPressColor: colorscheme.buttonDisabled,
tabBarItemStyle: {
width: undefined,
- paddingHorizontal: 8,
},
tabBarIndicatorStyle: {
backgroundColor: colorscheme.primary,
borderColor: colorscheme.background,
- borderStartWidth: 15,
- borderEndWidth: 15,
+ marginHorizontal: 15,
height: 3,
borderRadius: 2,
+ width: Dimensions.get('window').width / 2.5,
+ },
+ tabBarIndicatorContainerStyle: {
+ alignItems: 'center',
},
};
}, [theme, topTabsConfig]);
@@ -84,7 +86,7 @@ function FloatingNewTweetButton() {
<>
setShowComposer(true)}
style={[styles.fab, { backgroundColor: themeColors.primary }]}
>
diff --git a/src/navigation/app/bottomTabs/NotificationsNavigator.tsx b/src/navigation/app/bottomTabs/NotificationsNavigator.tsx
index 9acc50c34..3dd33eb51 100644
--- a/src/navigation/app/bottomTabs/NotificationsNavigator.tsx
+++ b/src/navigation/app/bottomTabs/NotificationsNavigator.tsx
@@ -1,7 +1,7 @@
/* eslint-disable react-native/no-unused-styles */
import { useMemo } from 'react';
-import { Pressable, StyleSheet, View } from 'react-native';
+import { Dimensions, Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import {
@@ -34,14 +34,17 @@ function NotificationsTabs() {
...topTabsConfig,
tabBarItemStyle: {
width: undefined,
- marginHorizontal: 5,
},
tabBarIndicatorStyle: {
backgroundColor: colorscheme.primary,
borderColor: colorscheme.background,
- borderStartWidth: 15,
- borderEndWidth: 15,
+ marginHorizontal: 15,
height: 3,
+ borderRadius: 2,
+ width: Dimensions.get('window').width / 2.5,
+ },
+ tabBarIndicatorContainerStyle: {
+ alignItems: 'center',
},
};
}, [theme, topTabsConfig]);
diff --git a/src/navigation/navigationRef.ts b/src/navigation/navigationRef.ts
index 82126ada4..2bec5a6e5 100644
--- a/src/navigation/navigationRef.ts
+++ b/src/navigation/navigationRef.ts
@@ -33,3 +33,9 @@ export function push(
navigationRef.dispatch(StackActions.push(name, params));
}
+
+export function goBack() {
+ if (!navigationRef.isReady()) return;
+
+ navigationRef.goBack();
+}
diff --git a/src/navigation/profile/ConnectionsNavigator.tsx b/src/navigation/profile/ConnectionsNavigator.tsx
index b3a2e90aa..56ed1cafe 100644
--- a/src/navigation/profile/ConnectionsNavigator.tsx
+++ b/src/navigation/profile/ConnectionsNavigator.tsx
@@ -38,10 +38,13 @@ export function ConnectionsNavigator() {
...topTabsConfig,
tabBarItemStyle: {
width: 'auto',
+ paddingHorizontal: 15,
},
tabBarIndicatorStyle: {
backgroundColor: colorscheme.primary,
borderColor: colorscheme.background,
+ height: 3,
+ width: 0.55,
},
};
}, [theme, topTabsConfig]);
diff --git a/src/navigation/profile/ProfileSetupNavigator.tsx b/src/navigation/profile/ProfileSetupNavigator.tsx
index 5029347fc..134114a99 100644
--- a/src/navigation/profile/ProfileSetupNavigator.tsx
+++ b/src/navigation/profile/ProfileSetupNavigator.tsx
@@ -13,6 +13,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import Logo from '@/components/ui/Logo';
import BioSetupScreen from '@/screens/profile/profileSetup/BioSetupScreen';
+import FollowSuggestionsScreen from '@/screens/profile/profileSetup/FollowSuggestionsScreen';
import InterestsSetupScreen from '@/screens/profile/profileSetup/InterestsSetupScreen';
import PictureSetupScreen from '@/screens/profile/profileSetup/PictureSetupScreen';
import UsernameSetupScreen from '@/screens/profile/profileSetup/UsernameSetupScreen';
@@ -62,25 +63,28 @@ export function ProfileSetupNavigator() {
return (
-
- handleBackButtonPress(
- activeChild,
- options.navigation as StackNavigationProp
- )
- }
- className="active:opacity-50"
- >
- {activeChild === PROFILE_SETUP.PHOTO ? (
-
- Cancel
-
- ) : (
-
- )}
-
-
+ {activeChild !== PROFILE_SETUP.FOLLOW_SUGGESTIONS ? (
+
+ handleBackButtonPress(
+ activeChild,
+ options.navigation as StackNavigationProp
+ )
+ }
+ className="active:opacity-50"
+ >
+ {activeChild === PROFILE_SETUP.PHOTO ? (
+
+ Cancel
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
@@ -100,6 +104,7 @@ export function ProfileSetupNavigator() {
+
);
}
diff --git a/src/navigation/profile/ProfileTabsNavigator.tsx b/src/navigation/profile/ProfileTabsNavigator.tsx
index 21a29e6f4..a437aeff2 100644
--- a/src/navigation/profile/ProfileTabsNavigator.tsx
+++ b/src/navigation/profile/ProfileTabsNavigator.tsx
@@ -1,17 +1,23 @@
import { useMemo } from 'react';
-import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
+import { Dimensions } from 'react-native';
+
+import {
+ createMaterialTopTabNavigator,
+ MaterialTopTabNavigationOptions,
+} from '@react-navigation/material-top-tabs';
import { useTopTabsConfig } from '@/hooks/navigation/useTopTabsConfig';
+import { useTheme } from '@/hooks/useTheme';
import ProfileLikesScreen from '@/screens/profile/profileTabs/ProfileLikesScreen';
import ProfileMediaScreen from '@/screens/profile/profileTabs/ProfileMediaScreen';
import ProfilePostsScreen from '@/screens/profile/profileTabs/ProfilePostsScreen';
import ProfileRepliesScreen from '@/screens/profile/profileTabs/ProfileRepliesScreen';
import { ProfileTabsParamList } from '@/types/navigation';
+import { colors } from '@/utils';
import { PROFILE_TABS } from '@/utils/navigation/routeNames';
const Tab = createMaterialTopTabNavigator();
-
interface ProfileTabsNavigatorProps {
username: string;
}
@@ -20,31 +26,67 @@ export function ProfileTabsNavigator({ username }: ProfileTabsNavigatorProps) {
const topTabsConfig = useTopTabsConfig();
const initialParams = useMemo(() => ({ username }), [username]);
+ const { theme } = useTheme();
+ const screenOptions: MaterialTopTabNavigationOptions = useMemo(() => {
+ const colorscheme = colors[theme];
+
+ return {
+ ...topTabsConfig,
+ tabBarItemStyle: {
+ width: undefined,
+ },
+ tabBarIndicatorStyle: {
+ backgroundColor: colorscheme.primary,
+ borderColor: colorscheme.background,
+ marginHorizontal: 15,
+ height: 3,
+ borderRadius: 2,
+ width: Dimensions.get('window').width / 5.5,
+ },
+ };
+ }, [theme, topTabsConfig]);
+
return (
-
+
);
diff --git a/src/navigation/settings/SettingsNavigator.tsx b/src/navigation/settings/SettingsNavigator.tsx
index adbb491fe..1ce464962 100644
--- a/src/navigation/settings/SettingsNavigator.tsx
+++ b/src/navigation/settings/SettingsNavigator.tsx
@@ -8,11 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import Button from '@/components/ui/Button';
import { useTheme } from '@/hooks/useTheme';
-import {
- AppearanceSettingsScreen,
- NotificationsSettingsScreen,
- SettingsScreen,
-} from '@/screens/settings';
+import { AppearanceSettingsScreen, SettingsScreen } from '@/screens/settings';
import { useUserStore } from '@/stores/userStore';
import { SettingsStackParamList } from '@/types/navigation';
@@ -66,7 +62,6 @@ export function SettingsNavigator() {
component={PrivacySettingsNavigator}
options={{ headerShown: false }}
/>
-
);
diff --git a/src/screens/StartScreen.tsx b/src/screens/StartScreen.tsx
index 48c01c339..d03120b0e 100644
--- a/src/screens/StartScreen.tsx
+++ b/src/screens/StartScreen.tsx
@@ -72,11 +72,22 @@ export default function StartScreen() {
By signing up, you agree to our
-
+ navigation.navigate(ROOT.TERMS_OF_SERVICE)}
+ >
+ {' '}
+ Terms of Service
+
+ ,
+ navigation.navigate(ROOT.PRIVACY_POLICY)}
+ >
{' '}
- Terms, Privacy Policy, Cookie Use
+ Privacy Policy
- .
+ , and Cookie Use.
diff --git a/src/screens/accountInformation/AccountInformationScreen.tsx b/src/screens/accountInformation/AccountInformationScreen.tsx
index fddb36742..d1aa51670 100644
--- a/src/screens/accountInformation/AccountInformationScreen.tsx
+++ b/src/screens/accountInformation/AccountInformationScreen.tsx
@@ -32,6 +32,7 @@ interface SettingRowProps {
onPress: () => void;
showArrow?: boolean;
testID?: string;
+ accessibilityLabel?: string;
}
export default function AccountInformationScreen() {
@@ -104,6 +105,7 @@ export default function AccountInformationScreen() {
onPress,
showArrow = true,
testID,
+ accessibilityLabel,
}) => (
{label}
-
+
{truncateText(value)}
{showArrow && (
@@ -162,6 +168,7 @@ export default function AccountInformationScreen() {
});
}}
testID="username-row"
+ accessibilityLabel="account-info-current-username"
/>
{searchQuery.length > 0 && (
diff --git a/src/screens/auth/OAuthComplete.tsx b/src/screens/auth/OAuthComplete.tsx
index 43d381178..2b4f971a6 100644
--- a/src/screens/auth/OAuthComplete.tsx
+++ b/src/screens/auth/OAuthComplete.tsx
@@ -12,7 +12,7 @@ import { AuthScreenOptionsProps, RootScreenOptionsProps } from '@/types/navigati
import { calculateAge } from '@/utils/calculateAge';
import { colors } from '@/utils/colorTheme';
import { handleApiFormError } from '@/utils/formErrors';
-import { AUTH, BOTTOM_TABS, DRAWER, HOME, ROOT } from '@/utils/navigation/routeNames';
+import { AUTH, ROOT } from '@/utils/navigation/routeNames';
import { createOAuthCompleteStyles } from './OAuthComplete.styles';
@@ -78,15 +78,7 @@ export default function OAuthComplete() {
navigation.reset({
index: 0,
- routes: [
- {
- name: ROOT.DRAWER,
- params: {
- screen: DRAWER.BOTTOM_TABS,
- params: { screen: BOTTOM_TABS.HOME, params: { screen: HOME.FOR_YOU } },
- },
- },
- ],
+ routes: [{ name: ROOT.PROFILE_SETUP }],
});
} else {
handleApiFormError(res, {
diff --git a/src/screens/auth/password/ConfirmCode.tsx b/src/screens/auth/password/ConfirmCode.tsx
index b5eaee4fa..59c0e461c 100644
--- a/src/screens/auth/password/ConfirmCode.tsx
+++ b/src/screens/auth/password/ConfirmCode.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { KeyboardAvoidingView, Text, View } from 'react-native';
@@ -37,6 +37,25 @@ export function ConfirmCode() {
const [hasChangedAfterError, setHasChangedAfterError] = useState(true);
const [resendLoading, setResendLoading] = useState(false);
const [resendMessage, setResendMessage] = useState(undefined);
+ const [resendTimer, setResendTimer] = useState(60);
+
+ useEffect(() => {
+ let interval: ReturnType | null = null;
+ if (resendTimer > 0) {
+ interval = setInterval(() => {
+ setResendTimer((prev) => {
+ if (prev <= 1) {
+ if (interval) clearInterval(interval);
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+ }
+ return () => {
+ if (interval) clearInterval(interval);
+ };
+ }, [resendTimer]);
const valid = useMemo(() => code.trim().length === 6, [code]);
@@ -85,6 +104,7 @@ export function ConfirmCode() {
};
const handleResend = async () => {
+ if (resendLoading || resendTimer > 0) return;
setResendLoading(true);
setResendMessage(undefined);
@@ -92,6 +112,7 @@ export function ConfirmCode() {
const response = await resendResetOtp({ confirmationToken });
if (response.success) {
setResendMessage('A new code has been sent to your email');
+ setResendTimer(60);
} else {
handleApiFormError(response, {
setFieldError: (field: string, message?: string) => {
@@ -167,16 +188,22 @@ export function ConfirmCode() {
- Didn't receive the code?{' '}
-
- {resendLoading ? 'Sending...' : 'Resend'}
-
+ {resendTimer > 0 ? (
+ `Resend code in ${resendTimer}s`
+ ) : (
+ <>
+ Didn't receive the code?{' '}
+ 0}
+ accessibilityLabel="confirm-code-resend-button"
+ >
+ {resendLoading ? 'Sending...' : 'Resend'}
+
+ >
+ )}
{resendMessage && (
{
- return (
-
- FollowerRequestsScreen
-
- );
-};
-export default FollowerRequestsScreen;
diff --git a/src/screens/explore/ForYouExploreScreen.tsx b/src/screens/explore/ForYouExploreScreen.tsx
index ca4774a63..7123afd8e 100644
--- a/src/screens/explore/ForYouExploreScreen.tsx
+++ b/src/screens/explore/ForYouExploreScreen.tsx
@@ -16,27 +16,49 @@ import { useTrending } from '@/hooks/explore/useTrending';
import { useDrawerSwipe } from '@/hooks/navigation/useDrawerSwipe';
import { useFeed } from '@/hooks/useFeed';
import { useTheme } from '@/hooks/useTheme';
+import { queryKeys } from '@/libs/queryKeys';
import { getForYouFeed } from '@/services/timeline';
+import { useUserStore } from '@/stores/userStore';
import { RootStackParamList } from '@/types/navigation';
import { Tweet } from '@/types/tweet';
import { colors } from '@/utils';
+import { isViewingSameProfile } from '@/utils/navigation/isViewingSameProfile';
import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames';
-type Block =
+export type Block =
| { type: 'trending'; key: string }
| { type: 'categories'; key: string }
- | { type: 'tweets-header'; key: string } // new block type
+ | { type: 'tweets-header'; key: string }
| { type: 'tweet'; key: string; tweet: Tweet };
+export const getItemType = (item: Block) => {
+ if (item.type === 'trending') return 'trending';
+ if (item.type === 'categories') return 'categories';
+ if (item.type === 'tweets-header') return 'tweets-header';
+
+ const t = item.tweet;
+ const hasMedia = (t.media?.length ?? 0) > 0;
+ const hasQuote = !!t.quotedTweet;
+
+ if (hasMedia && hasQuote) return `media-${t.media!.length}-quote`;
+ if (hasMedia) return `media-${t.media!.length}`;
+ if (hasQuote) return 'quote';
+ return 'text';
+};
+
const ForYouExploreScreen = () => {
- const { onTouchStart, onTouchEnd } = useDrawerSwipe();
- // const rootNavigation = useRootNavigation();
- const rootNavigation = useNavigation>();
+ const username = useUserStore((state) => state.user.username);
const { theme } = useTheme();
const currentColors = colors[theme];
- const feedResult = useFeed({ cacheKey: 'for-you-feed', fetcher: getForYouFeed });
+ const { onTouchStart, onTouchEnd } = useDrawerSwipe();
+ const rootNavigation = useNavigation>();
+
+ const feedResult = useFeed({
+ queryKey: queryKeys.timeline.forYou(username),
+ fetcher: getForYouFeed,
+ });
const trendingResult = useTrending();
const categoriesResult = useForYouCategories();
@@ -51,15 +73,17 @@ const ForYouExploreScreen = () => {
{ type: 'categories', key: 'categories' },
];
- const tweetHeader: Block = { type: 'tweets-header', key: 'tweets-header' };
-
const tweetBlocks = tweets.map((tweet) => ({
type: 'tweet' as const,
key: tweet.id,
tweet,
}));
- return [...base, tweetHeader, ...tweetBlocks];
+ if (tweets.length > 0) {
+ base.push({ type: 'tweets-header', key: 'tweets-header' });
+ }
+
+ return [...base, ...tweetBlocks];
}, [tweets]);
const loadMore = useCallback(() => {
@@ -68,11 +92,14 @@ const ForYouExploreScreen = () => {
}, [feedResult]);
const goToProfile = useCallback(
- (username: string) =>
- rootNavigation.navigate(ROOT.PROFILE, {
+ (username: string) => {
+ if (isViewingSameProfile(username)) return;
+
+ rootNavigation.push(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username },
- }),
+ });
+ },
[rootNavigation]
);
@@ -111,6 +138,9 @@ const ForYouExploreScreen = () => {
feedResult.isRefetching || trendingResult.isRefetching || categoriesResult.isRefetching;
const isError = feedResult.error || trendingResult.error || categoriesResult.error;
+ const hasCategories = (categoriesResult.data?.data?.categories?.length ?? 0) > 0;
+ const hasTrends = (trendingResult.data?.data?.length ?? 0) > 0;
+
const handleRefresh = () => {
if (!isRefetching) {
feedResult.refetch();
@@ -140,6 +170,8 @@ const ForYouExploreScreen = () => {
scrollEnabled={false}
emptyMessage="No trends available"
errorMessage="Failed to load trends"
+ allowLoader={false}
+ showRank={false}
/>
);
@@ -181,22 +213,13 @@ const ForYouExploreScreen = () => {
]
);
- const getItemType = (item: Block) => {
- if (item.type === 'trending') return 'trending';
- if (item.type === 'categories') return 'categories';
- if (item.type === 'tweets-header') return 'tweets-header';
-
- const t = item.tweet;
- const hasMedia = (t.media?.length ?? 0) > 0;
- const hasQuote = !!t.quotedTweet;
-
- if (hasMedia && hasQuote) return `media-${t.media!.length}-quote`;
- if (hasMedia) return `media-${t.media!.length}`;
- if (hasQuote) return 'quote';
- return 'text';
- };
+ const ItemSeparator = memo(({ leadingItem }: { leadingItem: Block }) => {
+ if (leadingItem?.type === 'tweets-header') return null;
+ if (leadingItem?.type === 'categories' && !hasCategories) return null;
+ if (leadingItem?.type === 'trending' && !hasTrends) return null;
- const ItemSeparator = memo(() => );
+ return ;
+ });
ItemSeparator.displayName = 'ItemSeparator';
const LoadingComponent = memo(() => (
@@ -242,9 +265,25 @@ const ForYouExploreScreen = () => {
);
}
- if (!feedResult.hasNextPage && !isLoading) {
+ if (feedResult.error) {
return (
+
+ Failed to load more posts
+
+
+ );
+ }
+
+ if (
+ !feedResult.hasNextPage &&
+ !isLoading &&
+ feedResult.data?.pages &&
+ feedResult.data.pages.length > 0 &&
+ !feedResult.error
+ ) {
+ return (
+
You're all caught up
@@ -253,7 +292,7 @@ const ForYouExploreScreen = () => {
}
return null;
- }, [feedResult.isFetchingNextPage, feedResult.hasNextPage, isLoading]);
+ }, [feedResult, isLoading]);
return (
{
ItemSeparatorComponent={ItemSeparator}
refreshControl={RefreshController}
testID="for-you-explore-flashlist"
+ accessibilityLabel="for-you-explore-flashlist"
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
/>
diff --git a/src/screens/explore/NewsScreen.tsx b/src/screens/explore/NewsScreen.tsx
index 3580a1084..a57fe4eb6 100644
--- a/src/screens/explore/NewsScreen.tsx
+++ b/src/screens/explore/NewsScreen.tsx
@@ -10,6 +10,7 @@ const NewsScreen = () => {
emptyMessage="No trending news available."
errorMessage="Failed to load trending news."
testID="news-list"
+ accessibilityLabel="news-list"
/>
);
};
diff --git a/src/screens/explore/TrendingScreen.tsx b/src/screens/explore/TrendingScreen.tsx
index 95729ad66..69989ff9b 100644
--- a/src/screens/explore/TrendingScreen.tsx
+++ b/src/screens/explore/TrendingScreen.tsx
@@ -10,6 +10,7 @@ const TrendingScreen = () => {
emptyMessage="No trending topics available."
errorMessage="Failed to load trending topics."
testID="trending-list"
+ accessibilityLabel="trending-list"
/>
);
};
diff --git a/src/screens/home/FollowingScreen.tsx b/src/screens/home/FollowingScreen.tsx
index 40c64b76f..60489d550 100644
--- a/src/screens/home/FollowingScreen.tsx
+++ b/src/screens/home/FollowingScreen.tsx
@@ -1,18 +1,35 @@
-import { useCallback } from 'react';
+import { useCallback, useRef, type ComponentRef } from 'react';
+
+import { StyleSheet, View } from 'react-native';
import { MaterialTopTabNavigationProp } from '@react-navigation/material-top-tabs';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
+import { FlashList } from '@shopify/flash-list';
+import NewTweetsIndicator from '@/components/ui/NewTweetsIndicator';
import TimelineFeedList from '@/components/ui/TimelineFeedList';
import { useFeed } from '@/hooks/useFeed';
+import { queryKeys } from '@/libs/queryKeys';
import { getFollowingFeed } from '@/services/timeline';
+import { useTimelineStore } from '@/stores/timelineStore';
+import { useUserStore } from '@/stores/userStore';
+import { Tweet } from '@/types/tweet';
import { HOME, ROOT, TWEET } from '@/utils/navigation/routeNames';
import type { HomeTabsParamList, RootStackParamList } from '@/types/navigation';
const FollowingScreen = () => {
- const feedResult = useFeed({ cacheKey: 'following-feed', fetcher: getFollowingFeed });
+ const listRef = useRef>>(null);
+ const username = useUserStore((state) => state.user.username);
+ const followingNewTweetAuthors = useTimelineStore((state) => state.followingNewTweetAuthors);
+ const clearFollowingNewTweetAuthors = useTimelineStore(
+ (state) => state.clearFollowingNewTweetAuthors
+ );
+ const feedResult = useFeed({
+ queryKey: queryKeys.timeline.following(username),
+ fetcher: getFollowingFeed,
+ });
const topTabNavigation =
useNavigation>();
const rootNavigation = topTabNavigation
@@ -26,13 +43,35 @@ const FollowingScreen = () => {
[rootNavigation]
);
+ const handleNewTweetsPress = useCallback(async () => {
+ listRef.current?.scrollToOffset({ offset: 0, animated: true });
+ clearFollowingNewTweetAuthors();
+ await feedResult.refetch();
+ }, [clearFollowingNewTweetAuthors, feedResult]);
+
return (
-
+
+ {followingNewTweetAuthors && followingNewTweetAuthors.length > 0 && (
+
+ )}
+
+
);
};
export default FollowingScreen;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+});
diff --git a/src/screens/home/ForYouHomeScreen.tsx b/src/screens/home/ForYouHomeScreen.tsx
index 788676552..476fe2982 100644
--- a/src/screens/home/ForYouHomeScreen.tsx
+++ b/src/screens/home/ForYouHomeScreen.tsx
@@ -1,18 +1,27 @@
-import { useCallback } from 'react';
+import { useCallback, useRef, type ComponentRef } from 'react';
import { MaterialTopTabNavigationProp } from '@react-navigation/material-top-tabs';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
+import { FlashList } from '@shopify/flash-list';
import TimelineFeedList from '@/components/ui/TimelineFeedList';
import { useFeed } from '@/hooks/useFeed';
+import { queryKeys } from '@/libs/queryKeys';
import { getForYouFeed } from '@/services/timeline';
+import { useUserStore } from '@/stores/userStore';
+import { Tweet } from '@/types/tweet';
import { HOME, ROOT, TWEET } from '@/utils/navigation/routeNames';
import type { HomeTabsParamList, RootStackParamList } from '@/types/navigation';
const ForYouScreen = () => {
- const feedResult = useFeed({ cacheKey: 'for-you-feed', fetcher: getForYouFeed });
+ const listRef = useRef>>(null);
+ const username = useUserStore((state) => state.user.username);
+ const feedResult = useFeed({
+ queryKey: queryKeys.timeline.forYou(username),
+ fetcher: getForYouFeed,
+ });
const topTabNavigation =
useNavigation>();
const rootNavigation = topTabNavigation
@@ -28,6 +37,7 @@ const ForYouScreen = () => {
return (
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: theme.background,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ contentContainer: {
+ padding: 20,
+ paddingBottom: 40,
+ },
+ lastUpdated: {
+ fontSize: 14,
+ color: theme.mutedForeground,
+ marginBottom: 24,
+ },
+ section: {
+ marginBottom: 24,
+ },
+ sectionTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ color: theme.foreground,
+ marginBottom: 12,
+ },
+ sectionContent: {
+ fontSize: 15,
+ lineHeight: 24,
+ color: theme.foreground,
+ },
+ bulletList: {
+ marginTop: 12,
+ gap: 8,
+ },
+ bulletItem: {
+ fontSize: 15,
+ lineHeight: 22,
+ color: theme.foreground,
+ paddingLeft: 8,
+ },
+ });
diff --git a/src/screens/legal/PrivacyPolicyScreen.tsx b/src/screens/legal/PrivacyPolicyScreen.tsx
new file mode 100644
index 000000000..80695b1b1
--- /dev/null
+++ b/src/screens/legal/PrivacyPolicyScreen.tsx
@@ -0,0 +1,128 @@
+/* eslint-disable react-native/no-raw-text */
+import { useMemo } from 'react';
+
+import { ScrollView, View } from 'react-native';
+
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import AppText from '@/components/ui/AppText';
+import { useTheme } from '@/hooks/useTheme';
+import { colors } from '@/utils/colorTheme';
+
+import { createLegalScreenStyles } from './LegalScreen.styles';
+
+export default function PrivacyPolicyScreen() {
+ const { theme } = useTheme();
+ const styles = useMemo(() => createLegalScreenStyles(colors[theme]), [theme]);
+
+ return (
+
+
+ Last Updated: December 15, 2025
+
+
+ 1. Information We Collect
+
+ We collect information you provide directly to us, including:
+
+
+
+ • Account information (name, email, username, birthdate)
+
+
+ • Content you post (tweets, messages, media)
+
+ • Usage data (interactions, preferences)
+
+ • Device information (device type, OS version)
+
+
+
+
+
+ 2. How We Use Your Information
+ We use the information we collect to:
+
+
+ • Provide, maintain, and improve our services
+
+
+ • Personalize your experience and content recommendations
+
+
+ • Communicate with you about updates and notifications
+
+ • Ensure security and prevent fraud
+
+
+
+
+ 3. Information Sharing
+
+ We do not sell your personal information. We may share information with service
+ providers who assist in operating our platform, or when required by law.
+
+
+
+
+ 4. Cookies and Tracking
+
+ We use cookies and similar technologies to remember your preferences, authenticate
+ sessions, and analyze platform usage to improve our services.
+
+
+
+
+ 5. Data Security
+
+ We implement appropriate security measures to protect your personal information,
+ including encryption, secure storage, and access controls.
+
+
+
+
+ 6. Your Rights
+ You have the right to:
+
+ • Access your personal data
+ • Correct inaccurate information
+ • Request deletion of your account
+ • Export your data
+
+
+
+
+ 7. Data Retention
+
+ We retain your information for as long as your account is active or as needed to provide
+ services. You can request account deletion at any time.
+
+
+
+
+ 8. Children's Privacy
+
+ Raven is not intended for users under 13 years of age. We do not knowingly collect
+ information from children under 13.
+
+
+
+
+ 9. Changes to This Policy
+
+ We may update this Privacy Policy from time to time. We will notify you of significant
+ changes through the app or via email.
+
+
+
+
+ 10. Contact Us
+
+ If you have questions about this Privacy Policy, please contact us at
+ raven.cufe@gmail.com.
+
+
+
+
+ );
+}
diff --git a/src/screens/legal/TermsOfServiceScreen.tsx b/src/screens/legal/TermsOfServiceScreen.tsx
new file mode 100644
index 000000000..a8d85dae9
--- /dev/null
+++ b/src/screens/legal/TermsOfServiceScreen.tsx
@@ -0,0 +1,110 @@
+/* eslint-disable react-native/no-raw-text */
+import { useMemo } from 'react';
+
+import { ScrollView, View } from 'react-native';
+
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import AppText from '@/components/ui/AppText';
+import { useTheme } from '@/hooks/useTheme';
+import { colors } from '@/utils/colorTheme';
+
+import { createLegalScreenStyles } from './LegalScreen.styles';
+
+export default function TermsOfServiceScreen() {
+ const { theme } = useTheme();
+ const styles = useMemo(() => createLegalScreenStyles(colors[theme]), [theme]);
+
+ return (
+
+
+ Last Updated: December 15, 2025
+
+
+ 1. Acceptance of Terms
+
+ By accessing or using Raven, you agree to be bound by these Terms of Service. If you do
+ not agree to these terms, please do not use the service.
+
+
+
+
+ 2. Eligibility
+
+ You must be at least 13 years old to use Raven. By using the service, you represent that
+ you meet this age requirement.
+
+
+
+
+ 3. Account Registration
+
+ You are responsible for maintaining the confidentiality of your account credentials and
+ for all activities under your account. You must provide accurate information when
+ creating an account.
+
+
+
+
+ 4. User Content
+
+ You retain ownership of content you post on Raven. By posting content, you grant Raven a
+ worldwide, non-exclusive license to use, display, and distribute your content on the
+ platform.
+
+
+
+
+ 5. Prohibited Conduct
+
+ When using Raven, you agree not to engage in any of the following:
+
+
+ • Spam or unsolicited advertising
+
+ • Harassment, bullying, or threatening behavior
+
+ • Impersonating other users or entities
+ • Posting illegal or harmful content
+ • Distributing malware or viruses
+
+ • Scraping or data mining without permission
+
+
+
+
+
+ 6. Termination
+
+ Raven reserves the right to suspend or terminate your account for violations of these
+ terms, at our sole discretion, without prior notice.
+
+
+
+
+ 7. Disclaimer of Warranties
+
+ Raven is provided "as is" without warranties of any kind. We do not guarantee
+ uninterrupted or error-free service.
+
+
+
+
+ 8. Changes to Terms
+
+ We may update these terms from time to time. Continued use of the service after changes
+ constitutes acceptance of the new terms.
+
+
+
+
+ 9. Contact Us
+
+ If you have any questions about these Terms of Service, please contact us at
+ raven.cufe@gmail.com.
+
+
+
+
+ );
+}
diff --git a/src/screens/legal/index.tsx b/src/screens/legal/index.tsx
new file mode 100644
index 000000000..a1a8425a7
--- /dev/null
+++ b/src/screens/legal/index.tsx
@@ -0,0 +1,2 @@
+export { default as TermsOfServiceScreen } from './TermsOfServiceScreen';
+export { default as PrivacyPolicyScreen } from './PrivacyPolicyScreen';
diff --git a/src/screens/messages/ChatScreen.tsx b/src/screens/messages/ChatScreen.tsx
index 8a85209f9..17f4eeaf2 100644
--- a/src/screens/messages/ChatScreen.tsx
+++ b/src/screens/messages/ChatScreen.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable react-native/no-raw-text */
import { useCallback, useEffect, useRef, useState } from 'react';
import {
@@ -77,6 +78,10 @@ const ChatScreen = () => {
const [showScrollButton, setShowScrollButton] = useState(false);
const [lastSeenMessageId, setLastSeenMessageId] = useState(null);
const [isOtherUserTyping, setIsOtherUserTyping] = useState(false);
+ const [isBlocked, setIsBlocked] = useState(false);
+ const [focusTrigger, setFocusTrigger] = useState(0);
+
+ const [isSocketConnected, setIsSocketConnected] = useState(false);
const flatListRef = useRef>(null);
const initialMarkSeenSent = useRef(false);
@@ -85,9 +90,46 @@ const ChatScreen = () => {
const setActiveConversationId = useDmStore((state) => state.setActiveConversationId);
+ useEffect(() => {
+ let cleanupFn: (() => void) | undefined;
+
+ const setupSocketListeners = () => {
+ try {
+ const socket = getSocket();
+
+ setIsSocketConnected(socket?.connected || false);
+
+ const onConnect = () => setIsSocketConnected(true);
+ const onDisconnect = () => setIsSocketConnected(false);
+
+ socket.on('connect', onConnect);
+ socket.on('disconnect', onDisconnect);
+
+ cleanupFn = () => {
+ socket.off('connect', onConnect);
+ socket.off('disconnect', onDisconnect);
+ };
+ } catch (e) {
+ console.error('Socket not initialized, retrying...', e);
+ const timer = setTimeout(setupSocketListeners, 500);
+ cleanupFn = () => clearTimeout(timer);
+ }
+ };
+
+ setupSocketListeners();
+
+ return () => {
+ if (cleanupFn) cleanupFn();
+ };
+ }, []);
+
useFocusEffect(
useCallback(() => {
setActiveConversationId(conversationId);
+ initialMarkSeenSent.current = false;
+ setIsBlocked(false);
+ setFocusTrigger((prev) => prev + 1);
+
return () => {
setActiveConversationId(null);
if (typingTimeoutRef.current) {
@@ -163,11 +205,13 @@ const ChatScreen = () => {
try {
const socket = getSocket();
- socket.emit('mark_seen', {
- type: 'mark_seen',
- conversationId,
- lastSeenMessageId,
- });
+ if (socket && socket.connected) {
+ socket.emit('mark_seen', {
+ type: 'mark_seen',
+ conversationId,
+ lastSeenMessageId,
+ });
+ }
} catch {}
},
[conversationId, markConversationSeenInCache]
@@ -212,10 +256,16 @@ const ChatScreen = () => {
}
: null,
onUserTyping: setIsOtherUserTyping,
+ onBlocked: (blocked: boolean) => setIsBlocked(blocked),
});
useEffect(() => {
- if (!initialMarkSeenSent.current && !isLoading && displayMessages.length > 0) {
+ if (
+ !initialMarkSeenSent.current &&
+ !isLoading &&
+ displayMessages.length > 0 &&
+ isSocketConnected
+ ) {
const first = displayMessages[0];
if (first && !first.id.startsWith('optimistic-')) {
if (first.isMine) {
@@ -225,7 +275,7 @@ const ChatScreen = () => {
}
initialMarkSeenSent.current = true;
}
- }, [displayMessages, markSeen, isLoading]);
+ }, [displayMessages, markSeen, isLoading, focusTrigger, isSocketConnected]);
useEffect(() => {
if (participant?.otherParticipantLastSeenMessageId) {
@@ -312,12 +362,13 @@ const ChatScreen = () => {
lastSeenMessageId={lastSeenMessageId}
conversationId={conversationId}
onDelete={deleteMessage}
+ disabled={isBlocked}
/>
{showDateSeparator && }
>
);
},
- [displayMessages, lastSeenMessageId, deleteMessage, conversationId]
+ [displayMessages, lastSeenMessageId, deleteMessage, conversationId, isBlocked]
);
if (isLoading) {
@@ -351,7 +402,17 @@ const ChatScreen = () => {
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
- ListHeaderComponent={isOtherUserTyping ? : null}
+ ListHeaderComponent={
+ isBlocked ? (
+
+
+ You can't send messages to this user
+
+
+ ) : isOtherUserTyping ? (
+
+ ) : null
+ }
ListEmptyComponent={
@@ -369,7 +430,7 @@ const ChatScreen = () => {
{showScrollButton && (
{
onSend={handleSend}
mediaUri={mediaUri}
onMediaSelect={handleMediaSelect}
+ accessibilityLabel="chat-input"
+ disabled={isBlocked}
/>
diff --git a/src/screens/messages/MessagesScreen.tsx b/src/screens/messages/MessagesScreen.tsx
index 92a4ad5fd..07318e48d 100644
--- a/src/screens/messages/MessagesScreen.tsx
+++ b/src/screens/messages/MessagesScreen.tsx
@@ -139,6 +139,7 @@ const MessagesScreen = () => {
onPress={() => openConversation(item)}
className="px-4 py-3 flex-row"
testID={`conversation-${item.id}`}
+ accessibilityLabel="conversation-list-item"
>
{
>
{p.displayName}
-
+
@{p.username}
{date && (
@@ -174,7 +180,7 @@ const MessagesScreen = () => {
{isUnread && (
)}
@@ -183,6 +189,7 @@ const MessagesScreen = () => {
style={[styles.textMuted, isUnread && styles.unreadPreview]}
className="mt-0.5"
numberOfLines={1}
+ accessibilityLabel="conversation-preview"
>
{preview}
@@ -234,7 +241,7 @@ const MessagesScreen = () => {
)}
setIsComposerOpen(true)}
className="absolute bottom-8 right-6 bg-primary w-14 h-14 rounded-full items-center justify-center shadow"
>
diff --git a/src/screens/playground/PlaygroundScreen.tsx b/src/screens/playground/PlaygroundScreen.tsx
index 76848e4c1..696b0fa06 100644
--- a/src/screens/playground/PlaygroundScreen.tsx
+++ b/src/screens/playground/PlaygroundScreen.tsx
@@ -118,7 +118,7 @@ export default function PlaygroundScreen() {
diff --git a/src/screens/profile/EditProfileScreen.tsx b/src/screens/profile/EditProfileScreen.tsx
index 84fc1834b..78c40d4f9 100644
--- a/src/screens/profile/EditProfileScreen.tsx
+++ b/src/screens/profile/EditProfileScreen.tsx
@@ -16,6 +16,7 @@ import ConfirmationDialog from '@/components/ui/ConfirmationDialog';
import Spinner from '@/components/ui/Spinner';
import { formatBirthDate } from '@/components/utils/profile';
import { useTheme } from '@/hooks/useTheme';
+import { getTweetCache } from '@/libs/tweetCache';
import { updateMyProfile } from '@/services/me';
import { useSessionStore } from '@/stores/sessionStore';
import { useUserStore } from '@/stores/userStore';
@@ -326,11 +327,7 @@ const EditProfileScreen = () => {
...updateProfileResponse.data,
}));
- queryClient.invalidateQueries({ queryKey: ['userTweets', user.username] });
- queryClient.invalidateQueries({ queryKey: ['userReplies', user.username] });
- queryClient.invalidateQueries({ queryKey: ['userMedia', user.username] });
- queryClient.invalidateQueries({ queryKey: ['userLikes', user.username] });
- queryClient.invalidateQueries({ queryKey: ['following-feed'] });
+ getTweetCache(queryClient).invalidateAllTweetQueries();
// allow navigation without warning since changes are saved
canNavigateAway.current = true;
diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx
index c85962839..3d12a33ad 100644
--- a/src/screens/profile/ProfileScreen.tsx
+++ b/src/screens/profile/ProfileScreen.tsx
@@ -36,7 +36,7 @@ const ProfileScreen = () => {
}, [profile]);
const blockedTab = (
-
+
@{profile?.username} is blocked
@@ -91,7 +91,7 @@ const ProfileScreen = () => {
if (profile) {
return (
-
+
{
+ const renderUser = ({ item }: { item: CompactUser }) => {
+ const followsYou = item.relationship?.follower ?? false;
+ const isFollowing = item.relationship?.following ?? false;
+
return (
diff --git a/src/screens/profile/connections/FollowingScreen.tsx b/src/screens/profile/connections/FollowingScreen.tsx
index f559d59bf..8c55648e3 100644
--- a/src/screens/profile/connections/FollowingScreen.tsx
+++ b/src/screens/profile/connections/FollowingScreen.tsx
@@ -11,7 +11,7 @@ import { PROFILE, ROOT } from '@/utils/navigation/routeNames';
import { getConnectionScreenStyles } from './Connections.styles';
-import type { FollowingUser } from '@/types/user';
+import type { CompactUser } from '@/types/user';
type FollowingScreenProps = {
username: string;
@@ -30,6 +30,7 @@ export default function FollowingScreen({ username }: FollowingScreenProps) {
fetchNextPage,
error,
refetch,
+
isRefetching,
} = useUserFollowing(username);
@@ -41,7 +42,10 @@ export default function FollowingScreen({ username }: FollowingScreenProps) {
params: { username: userHandle },
});
- const renderUser = ({ item }: { item: FollowingUser }) => {
+ const renderUser = ({ item }: { item: CompactUser }) => {
+ const followsYou = item.relationship?.follower ?? false;
+ const isFollowing = item.relationship?.following ?? false;
+
return (
diff --git a/src/screens/profile/profileSetup/BioSetupScreen.tsx b/src/screens/profile/profileSetup/BioSetupScreen.tsx
index 8bd91be80..b230f416a 100644
--- a/src/screens/profile/profileSetup/BioSetupScreen.tsx
+++ b/src/screens/profile/profileSetup/BioSetupScreen.tsx
@@ -9,6 +9,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { BioInput } from '@/components/ui/BioInput';
import Button from '@/components/ui/Button';
import { useTheme } from '@/hooks/useTheme';
+import { getTweetCache } from '@/libs/tweetCache';
import { getMyProfile, updateMyProfile, updateProfilePicture } from '@/services/me';
import { changeUsername, updateInterests } from '@/services/settings';
import { useSessionStore } from '@/stores/sessionStore';
@@ -27,6 +28,9 @@ import {
import { getBioSetupStyles } from './styles';
type RootNavigationProp = RootScreenOptionsProps<(typeof ROOT)['PROFILE_SETUP']>['navigation'];
+type ProfileSetupNavigationProp = ProfileSetupScreenOptionsProps<
+ (typeof PROFILE_SETUP)['BIO']
+>['navigation'];
type BioSetupRouteProp = ProfileSetupScreenOptionsProps<(typeof PROFILE_SETUP)['BIO']>['route'];
const MAX_BIO_LENGTH = 160;
@@ -44,6 +48,7 @@ export default function ProfileSetupBio() {
const [isLoading, setIsLoading] = useState(false);
const queryClient = useQueryClient();
+ const navigation = useNavigation();
const navigateToHome = () => {
rootNavigation.reset({
@@ -167,17 +172,11 @@ export default function ProfileSetupBio() {
const prevRoute = state?.routes[state.routes.length - 2];
if (prevRoute?.name === ROOT.PROFILE) {
- // invalidate tweets in profile tabs
- const newUsername = username ?? user.username;
- queryClient.invalidateQueries({ queryKey: ['userTweets', newUsername] });
- queryClient.invalidateQueries({ queryKey: ['userReplies', newUsername] });
- queryClient.invalidateQueries({ queryKey: ['userMedia', newUsername] });
- queryClient.invalidateQueries({ queryKey: ['userLikes', newUsername] });
- queryClient.invalidateQueries({ queryKey: ['following-feed'] });
+ getTweetCache(queryClient).invalidateAllTweetQueries();
navigateToProfile(username ?? user.username);
} else {
- navigateToHome();
+ navigation.navigate(PROFILE_SETUP.FOLLOW_SUGGESTIONS as never);
}
} catch (err) {
handleApiFormError(err, { setGlobalError: (m) => setError(m ?? null) });
diff --git a/src/screens/profile/profileSetup/FollowSuggestionsScreen.tsx b/src/screens/profile/profileSetup/FollowSuggestionsScreen.tsx
new file mode 100644
index 000000000..e60f1513e
--- /dev/null
+++ b/src/screens/profile/profileSetup/FollowSuggestionsScreen.tsx
@@ -0,0 +1,189 @@
+import { useEffect, useState } from 'react';
+
+import { Image, ScrollView, Text, View } from 'react-native';
+
+import { useNavigation } from '@react-navigation/native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import FollowButton from '@/components/profile/FollowButton';
+import Button from '@/components/ui/Button';
+import Spinner from '@/components/ui/Spinner';
+import { useTheme } from '@/hooks/useTheme';
+import { fetchFollowSuggestions } from '@/services/onBoarding';
+import { RootScreenOptionsProps } from '@/types/navigation';
+import { FollowUserSuggestion } from '@/types/user';
+import { handleApiFormError } from '@/utils/formErrors';
+import { BOTTOM_TABS, DRAWER, HOME, ROOT } from '@/utils/navigation/routeNames';
+
+import { getFollowSuggestionsStyles } from './styles';
+
+type RootNavigationProp = RootScreenOptionsProps<(typeof ROOT)['PROFILE_SETUP']>['navigation'];
+
+export default function FollowSuggestionsScreen() {
+ const { theme } = useTheme();
+ const styles = getFollowSuggestionsStyles(theme);
+ const rootNavigation = useNavigation();
+
+ const [suggestions, setSuggestions] = useState([]);
+ const [followedUsers, setFollowedUsers] = useState>(new Set());
+ const [isLoading, setIsLoading] = useState(true);
+ const [suggestionsError, setSuggestionsError] = useState(null);
+ const setSuggestionsGlobalError = (msg?: string | null) => setSuggestionsError(msg ?? null);
+
+ const loadSuggestions = async () => {
+ setIsLoading(true);
+ setSuggestionsError(null);
+ try {
+ const response = await fetchFollowSuggestions();
+ if (response.success && response.data.suggestions) {
+ setSuggestions(response.data.suggestions);
+ } else {
+ handleApiFormError(response, { setGlobalError: setSuggestionsGlobalError });
+ setSuggestions([]);
+ }
+ } catch (error) {
+ handleApiFormError(error, { setGlobalError: setSuggestionsGlobalError });
+ setSuggestions([]);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadSuggestions();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const navigateToHome = () => {
+ rootNavigation.reset({
+ index: 0,
+ routes: [
+ {
+ name: ROOT.DRAWER,
+ params: {
+ screen: DRAWER.BOTTOM_TABS,
+ params: { screen: BOTTOM_TABS.HOME, params: { screen: HOME.FOR_YOU } },
+ },
+ },
+ ],
+ });
+ };
+
+ const renderUserCard = (user: FollowUserSuggestion) => {
+ return (
+
+
+
+ {user.avatarUrl ? (
+
+ ) : (
+
+ {user.displayName.charAt(0).toUpperCase()}
+
+ )}
+
+
+
+ {user.displayName}
+
+
+ @{user.username}
+
+
+ {
+ setFollowedUsers((prev) => {
+ const newSet = new Set(prev);
+ if (isFollowing) {
+ newSet.add(username);
+ } else {
+ newSet.delete(username);
+ }
+ return newSet;
+ });
+ }}
+ />
+
+ {user.bio && (
+
+ {user.bio}
+
+ )}
+
+ );
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ Follow 1 or more accounts
+
+ When you follow someone, you'll see their posts in your Timeline. You'll also
+ get more relevant recommendations.
+
+
+ {suggestionsError ? (
+
+ {suggestionsError}
+ {
+ setSuggestionsError(null);
+ setIsLoading(true);
+ loadSuggestions();
+ }}
+ variant="ghost-primary"
+ size="sm"
+ title="Retry"
+ testID="retry-suggestions"
+ accessibilityLabel="Retry loading suggestions"
+ />
+
+ ) : null}
+
+ {suggestions.length > 0 ? (
+
+ You may be interested in
+ {suggestions.map((user) => renderUserCard(user))}
+
+ ) : (
+
+ No suggestions available at the moment.
+
+ )}
+
+
+
+
+ {
+ navigateToHome();
+ }}
+ variant="default"
+ size="md"
+ title="Next"
+ disabled={followedUsers.size === 0}
+ testID="next-button"
+ accessibilityLabel="Next"
+ />
+
+
+
+ );
+}
diff --git a/src/screens/profile/profileSetup/UsernameSetupScreen.tsx b/src/screens/profile/profileSetup/UsernameSetupScreen.tsx
index f1108830f..7848bfca5 100644
--- a/src/screens/profile/profileSetup/UsernameSetupScreen.tsx
+++ b/src/screens/profile/profileSetup/UsernameSetupScreen.tsx
@@ -251,7 +251,7 @@ export default function ProfileSetupUsername() {
title={`@${suggestion},`}
className="m-1"
testID={`suggestion-${index}`}
- accessibilityLabel={`Suggestion ${suggestion}`}
+ accessibilityLabel="onboarding-username-suggestion"
/>
))}
diff --git a/src/screens/profile/profileSetup/styles.ts b/src/screens/profile/profileSetup/styles.ts
index 92cb77e66..c05ddb46f 100644
--- a/src/screens/profile/profileSetup/styles.ts
+++ b/src/screens/profile/profileSetup/styles.ts
@@ -410,3 +410,144 @@ export function getInterestsSetupStyles(theme: 'dark' | 'light') {
},
});
}
+
+export function getFollowSuggestionsStyles(theme: 'dark' | 'light') {
+ const currentColors = colors[theme];
+
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: currentColors.background,
+ marginTop: -40,
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: currentColors.background,
+ },
+ content: {
+ flex: 1,
+ paddingHorizontal: 20,
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: '700',
+ marginTop: 20,
+ marginBottom: 8,
+ color: currentColors.foreground,
+ },
+ subtitle: {
+ fontSize: 16,
+ marginBottom: 24,
+ color: currentColors.mutedForeground,
+ lineHeight: 20,
+ },
+ sectionContainer: {
+ marginBottom: 24,
+ },
+ sectionTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ marginBottom: 16,
+ color: currentColors.foreground,
+ },
+ userList: {
+ gap: 12,
+ },
+ userCard: {
+ paddingVertical: 12,
+ gap: 8,
+ },
+ userTopRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ userInfo: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ flex: 1,
+ marginRight: 12,
+ },
+ avatarContainer: {
+ marginRight: 12,
+ },
+ avatar: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ },
+ avatarPlaceholder: {
+ backgroundColor: currentColors.accent,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ avatarText: {
+ fontSize: 20,
+ fontWeight: '600',
+ color: currentColors.foreground,
+ },
+ userDetails: {
+ flex: 1,
+ },
+ displayName: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: currentColors.foreground,
+ marginBottom: 2,
+ },
+ username: {
+ fontSize: 14,
+ color: currentColors.mutedForeground,
+ marginBottom: 4,
+ },
+ bio: {
+ fontSize: 14,
+ color: currentColors.foreground,
+ lineHeight: 18,
+ paddingLeft: 60,
+ },
+ followButton: {
+ paddingHorizontal: 24,
+ paddingVertical: 6,
+ borderRadius: 20,
+ backgroundColor: currentColors.foreground,
+ minWidth: 110,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ followButtonActive: {
+ backgroundColor: 'transparent',
+ borderWidth: 1,
+ borderColor: currentColors.border,
+ },
+ followButtonText: {
+ fontSize: 14,
+ fontWeight: '700',
+ color: currentColors.background,
+ },
+ followButtonTextActive: {
+ color: currentColors.foreground,
+ },
+ emptyState: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 40,
+ },
+ emptyStateText: {
+ fontSize: 16,
+ color: currentColors.mutedForeground,
+ },
+ bottomContainer: {
+ paddingHorizontal: 20,
+ paddingBottom: 32,
+ paddingTop: 16,
+ backgroundColor: currentColors.background,
+ },
+ bottomRow: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ },
+ });
+}
diff --git a/src/screens/profile/profileTabs/ProfileMutualsScreen.tsx b/src/screens/profile/profileTabs/ProfileMutualsScreen.tsx
index 95b283565..6718de012 100644
--- a/src/screens/profile/profileTabs/ProfileMutualsScreen.tsx
+++ b/src/screens/profile/profileTabs/ProfileMutualsScreen.tsx
@@ -1,17 +1,17 @@
import { useMemo } from 'react';
-import { FlatList, RefreshControl, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
-import FollowButton from '@/components/profile/FollowButton';
-import Avatar from '@/components/ui/Avatar';
+import ConnectionListItem from '@/components/profile/ConnectionListItem';
import Spinner from '@/components/ui/Spinner';
import { useUserMutuals } from '@/hooks/profile/useUserMutuals';
import { useTheme } from '@/hooks/useTheme';
import { navigationRef } from '@/navigation/navigationRef';
+import { useUserStore } from '@/stores/userStore';
import { colors } from '@/utils/colorTheme';
import { PROFILE, ROOT } from '@/utils/navigation/routeNames';
-import type { FollowingUser } from '@/types/user';
+import type { CompactUser } from '@/types/user';
type ProfileMutualsScreenProps = {
username: string;
@@ -20,6 +20,7 @@ type ProfileMutualsScreenProps = {
export default function ProfileMutualsScreen({ username }: ProfileMutualsScreenProps) {
const { theme } = useTheme();
const styles = useMemo(() => getStyles(theme), [theme]);
+ const currentUser = useUserStore((state) => state.user);
const {
data,
@@ -34,38 +35,28 @@ export default function ProfileMutualsScreen({ username }: ProfileMutualsScreenP
const mutuals = data?.pages.flatMap((page) => page.data) ?? [];
- const renderUser = ({ item }: { item: FollowingUser }) => {
+ const openProfile = (userHandle: string) =>
+ navigationRef.navigate(ROOT.PROFILE, {
+ screen: PROFILE.USER_PROFILE,
+ params: { username: userHandle },
+ });
+
+ const renderUser = ({ item }: { item: CompactUser }) => {
+ const followsYou = item.relationship?.follower ?? false;
+ const isFollowing = item.relationship?.following ?? false;
+
return (
-
-
-
- navigationRef.navigate(ROOT.PROFILE, {
- screen: PROFILE.USER_PROFILE,
- params: { username: item.username },
- })
- }
- >
-
-
-
-
-
+
);
};
diff --git a/src/screens/profile/profileTabs/ProfilePostsScreen.tsx b/src/screens/profile/profileTabs/ProfilePostsScreen.tsx
index 827fbd9a1..1458cf464 100644
--- a/src/screens/profile/profileTabs/ProfilePostsScreen.tsx
+++ b/src/screens/profile/profileTabs/ProfilePostsScreen.tsx
@@ -28,6 +28,7 @@ export default function ProfilePostsScreen() {
username: profile.username,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
+ relationship: profile.relationship,
}
: undefined;
@@ -51,6 +52,7 @@ export default function ProfilePostsScreen() {
emptySubMessage="When this account posts something, it'll show up here."
timelineUser={timelineUser}
blockedBy={profile?.relationship?.blockedBy}
+ username={username}
/>
);
}
diff --git a/src/screens/search/SearchFiltersScreen.tsx b/src/screens/search/SearchFiltersScreen.tsx
index 1e251fdf4..1c5fea8ae 100644
--- a/src/screens/search/SearchFiltersScreen.tsx
+++ b/src/screens/search/SearchFiltersScreen.tsx
@@ -54,13 +54,19 @@ const SearchFiltersScreen = () => {
]);
return (
-
+
Cancel
@@ -76,6 +82,7 @@ const SearchFiltersScreen = () => {
accessibilityRole="button"
disabled={!hasChanges}
testID="search-filters-apply"
+ accessibilityLabel="search-filters-apply"
className="w-16 items-end"
>
@@ -85,7 +92,11 @@ const SearchFiltersScreen = () => {
-
+
People
{
-
+
Safety
{
@@ -73,6 +75,8 @@ const SearchScreen = () => {
setSubmittedQuery(trimmed);
if (!trimmed.length) setActiveTab('top');
+ if (trimmed.length) queryClient.invalidateQueries({ queryKey: ['search'] });
+
Keyboard.dismiss();
setIsEditing(false);
}, [query]);
@@ -81,6 +85,8 @@ const SearchScreen = () => {
setQuery(value);
setSubmittedQuery(value.trim());
if (preferredTab) setActiveTab(preferredTab);
+ if (value.trim().length) queryClient.invalidateQueries({ queryKey: ['search'] });
+
Keyboard.dismiss();
setIsEditing(false);
}, []);
@@ -93,11 +99,14 @@ const SearchScreen = () => {
}, []);
const goToProfile = useCallback(
- (username: string) =>
- navigation.navigate(ROOT.PROFILE, {
+ (username: string) => {
+ if (isViewingSameProfile(username)) return;
+
+ navigation.push(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username },
- }),
+ });
+ },
[navigation]
);
@@ -142,6 +151,7 @@ const SearchScreen = () => {
accessibilityRole="button"
hitSlop={12}
testID="search-back"
+ accessibilityLabel="search-back"
>
@@ -174,6 +184,7 @@ const SearchScreen = () => {
autoCorrect={false}
autoCapitalize="none"
testID="search-input"
+ accessibilityLabel="search-input"
/>
{!showResults && query.length > 0 ? (
@@ -182,6 +193,7 @@ const SearchScreen = () => {
accessibilityRole="button"
hitSlop={8}
testID="clear-search"
+ accessibilityLabel="clear-search"
>
@@ -205,11 +217,16 @@ const SearchScreen = () => {
accessibilityRole="button"
hitSlop={12}
testID="search-gear"
+ accessibilityLabel="search-gear"
>
) : (
-
+
Cancel
)}
diff --git a/src/screens/settings/NotificationsSettingsScreen.tsx b/src/screens/settings/NotificationsSettingsScreen.tsx
deleted file mode 100644
index b1ade38d0..000000000
--- a/src/screens/settings/NotificationsSettingsScreen.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Text, View } from 'react-native';
-
-const NotificationsSettingsScreen = () => {
- return (
-
- NotificationsSettingsScreen
-
- );
-};
-export default NotificationsSettingsScreen;
diff --git a/src/screens/settings/SettingsScreen.tsx b/src/screens/settings/SettingsScreen.tsx
index 4ae02329f..8dcff7bb9 100644
--- a/src/screens/settings/SettingsScreen.tsx
+++ b/src/screens/settings/SettingsScreen.tsx
@@ -3,23 +3,27 @@ import { useMemo } from 'react';
import { ScrollView, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
+import { StackNavigationProp } from '@react-navigation/stack';
import {
AccountInfoSettingsCard,
AppearanceSettingsCard,
- NotificationsSettingsCard,
+ PrivacyPolicySettingsCard,
PrivacySettingsCard,
+ TermsOfServiceSettingsCard,
} from '@/components/settings/SettingsSections';
import { useTheme } from '@/hooks/useTheme';
-import { SettingsScreenOptionsProps } from '@/types/navigation';
-import { ACCOUNT_SETTINGS, PRIVACY_SETTINGS, SETTINGS } from '@/utils/navigation/routeNames';
+import { RootStackParamList, SettingsScreenOptionsProps } from '@/types/navigation';
+import { ACCOUNT_SETTINGS, PRIVACY_SETTINGS, ROOT, SETTINGS } from '@/utils/navigation/routeNames';
import { createSettingsScreenStyles } from './SettingsScreen.styles';
type SettingsNavigationProp = SettingsScreenOptionsProps<(typeof SETTINGS)['MAIN']>['navigation'];
+type RootNavigationProp = StackNavigationProp;
export default function SettingsScreen() {
const navigation = useNavigation();
+ const rootNavigation = useNavigation();
const { theme } = useTheme();
const styles = useMemo(() => createSettingsScreenStyles(theme), [theme]);
@@ -35,9 +39,13 @@ export default function SettingsScreen() {
onPress={() => navigation.push(SETTINGS.PRIVACY, { screen: PRIVACY_SETTINGS.MAIN })}
/>
- navigation.push(SETTINGS.NOTIFICATIONS)} />
-
navigation.push(SETTINGS.APPEARANCE)} />
+
+ rootNavigation.navigate(ROOT.TERMS_OF_SERVICE)}
+ />
+
+ rootNavigation.navigate(ROOT.PRIVACY_POLICY)} />
diff --git a/src/screens/settings/index.tsx b/src/screens/settings/index.tsx
index bcd1764c6..45c6de0c3 100644
--- a/src/screens/settings/index.tsx
+++ b/src/screens/settings/index.tsx
@@ -1,5 +1,4 @@
export { default as AccountSettingsScreen } from './AccountSettingsScreen';
export { default as AppearanceSettingsScreen } from './AppearanceSettingsScreen';
-export { default as NotificationsSettingsScreen } from './NotificationsSettingsScreen';
export { default as PrivacySettingsScreen } from './privacy/PrivacySettingsScreen';
export { default as SettingsScreen } from './SettingsScreen';
diff --git a/src/screens/settings/privacy/BlockedAccountsScreen.tsx b/src/screens/settings/privacy/BlockedAccountsScreen.tsx
index 32583bbfc..70bbddc13 100644
--- a/src/screens/settings/privacy/BlockedAccountsScreen.tsx
+++ b/src/screens/settings/privacy/BlockedAccountsScreen.tsx
@@ -68,7 +68,7 @@ const BlockedAccountsScreen = () => {
"When you block someone, they won't be able to follow or message you, and you won't see notifications from them.";
return (
-
+
item.username}
diff --git a/src/screens/settings/privacy/ChangeInterestsScreen.tsx b/src/screens/settings/privacy/ChangeInterestsScreen.tsx
index 087898c19..88a8e1614 100644
--- a/src/screens/settings/privacy/ChangeInterestsScreen.tsx
+++ b/src/screens/settings/privacy/ChangeInterestsScreen.tsx
@@ -134,6 +134,7 @@ const ChangeInterestsScreen = () => {
{
+ const renderUser = ({ item }: { item: CompactUser }) => {
return (
);
};
diff --git a/src/screens/tweets/QuotesScreen.tsx b/src/screens/tweets/QuotesScreen.tsx
index c08f23d4a..aced1a826 100644
--- a/src/screens/tweets/QuotesScreen.tsx
+++ b/src/screens/tweets/QuotesScreen.tsx
@@ -12,6 +12,7 @@ import { useTheme } from '@/hooks/useTheme';
import { RootStackParamList, TweetStackParamList } from '@/types/navigation';
import { Tweet as TweetType } from '@/types/tweet';
import { colors } from '@/utils/colorTheme';
+import { isViewingSameProfile } from '@/utils/navigation/isViewingSameProfile';
import { PROFILE, ROOT, TWEET } from '@/utils/navigation/routeNames';
import { createQuotesScreenStyles } from './QuotesScreen.styles';
@@ -41,11 +42,14 @@ export default function QuotesScreen({ tweetId }: QuotesScreenProps) {
const quotes = data?.pages.flatMap((page) => page.data) ?? [];
const goToProfile = useCallback(
- (username: string) =>
- rootNavigation.navigate(ROOT.PROFILE, {
+ (username: string) => {
+ if (isViewingSameProfile(username)) return;
+
+ rootNavigation.push(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username },
- }),
+ });
+ },
[rootNavigation]
);
@@ -79,6 +83,18 @@ export default function QuotesScreen({ tweetId }: QuotesScreenProps) {
[rootNavigation]
);
+ const handleReplyPress = useCallback(
+ (tweetParam: TweetType) => {
+ if (!tweetParam.id.trim()) return;
+
+ rootNavigation.navigate(ROOT.TWEET, {
+ screen: TWEET.DETAIL,
+ params: { tweetId: tweetParam.id.trim(), initialOpenComposer: true },
+ });
+ },
+ [rootNavigation]
+ );
+
const renderTweet = ({ item }: { item: TweetType }) => {
return (
diff --git a/src/screens/tweets/RetweetersScreen.tsx b/src/screens/tweets/RetweetersScreen.tsx
index 518cef245..46f6507ae 100644
--- a/src/screens/tweets/RetweetersScreen.tsx
+++ b/src/screens/tweets/RetweetersScreen.tsx
@@ -5,8 +5,8 @@ import Spinner from '@/components/ui/Spinner';
import { useTweetRetweeters } from '@/hooks/tweets/useTweetRetweeters';
import { useTheme } from '@/hooks/useTheme';
import { navigationRef } from '@/navigation/navigationRef';
-import { UserMetadataWithBio } from '@/services/tweets';
import { useUserStore } from '@/stores/userStore';
+import { CompactUser } from '@/types/user';
import { colors } from '@/utils/colorTheme';
import { PROFILE, ROOT } from '@/utils/navigation/routeNames';
@@ -40,16 +40,18 @@ export default function RetweetersScreen({ tweetId }: RetweetersScreenProps) {
params: { username: userHandle },
});
- const renderUser = ({ item }: { item: UserMetadataWithBio }) => {
+ const renderUser = ({ item }: { item: CompactUser }) => {
return (
();
const route = useRoute();
- const { tweetId } = route.params;
+ const { tweetId, initialOpenComposer } = route.params;
const rootNavigation = useNavigation>();
const { theme } = useTheme();
const currentColors = colors[theme];
- const [mainTweet, setMainTweet] = useState(null);
- const [rootTweet, setRootTweet] = useState(null);
- const [parentTweets, setParentTweets] = useState<(Tweet | DeletedTweet)[]>([]);
- const [hasMoreParents, setHasMoreParents] = useState(false);
+ const tweetCache = getTweetCache(queryClient);
+ const {
+ data: tweetData,
+ isLoading: loadingTweet,
+ error: tweetError,
+ refetch: refetchTweet,
+ } = useTweetDetail(tweetId);
+
+ const {
+ data: repliesData,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ refetch: refetchReplies,
+ isLoading: loadingReplies,
+ } = useTweetReplies(tweetId);
+
+ const { data: mainTweet } = useQuery({
+ queryKey: queryKeys.tweet(tweetId),
+ queryFn: () => tweetCache.getTweet(tweetId),
+ staleTime: Infinity,
+ gcTime: Infinity,
+ initialData: tweetData,
+ });
+
+ const rootTweet = tweetData?.rootTweet || null;
+ const parentTweets = useMemo(() => tweetData?.parentTweets || [], [tweetData?.parentTweets]);
+ const hasMoreParents = tweetData?.hasMoreParents || false;
+ const replies = useMemo(
+ () => repliesData?.pages.flatMap((page) => page.data) ?? [],
+ [repliesData?.pages]
+ );
const flashListRef = useRef>(null);
- const [replies, setReplies] = useState([]);
- const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
- const [loadingMore, setLoadingMore] = useState(false);
- const [nextCursor, setNextCursor] = useState(null);
- const [hasMore, setHasMore] = useState(true);
const [replyInputFocused, setReplyInputFocused] = useState(false);
const [replyText, setReplyText] = useState('');
const [refreshKey, setRefreshKey] = useState(0);
- const [error, setError] = useState(null);
const [replyError, setReplyError] = useState(null);
const [quoteTweet, setQuoteTweet] = useState(null);
+
+ const loading = loadingTweet || loadingReplies;
+ const error = tweetError
+ ? tweetError instanceof Error
+ ? tweetError.message
+ : 'Failed to load tweet'
+ : null;
const [showQuoteComposer, setShowQuoteComposer] = useState(false);
const onQuoteSuccessCallbackRef = useRef<((tweet: CompactTweet) => void) | null>(null);
const [composerVisible, setComposerVisible] = useState(false);
const [composerOpenWithMedia, setComposerOpenWithMedia] = useState(false);
+ const [replyToTweetData, setReplyToTweetData] = useState(null);
+ const hasHandledInitialComposerRef = useRef(false);
- const headerHeight = useHeaderHeight();
+ //const headerHeight = useHeaderHeight();
const styles = useMemo(
() => getTweetDetailScreenStyles(theme, replyInputFocused),
@@ -81,76 +118,28 @@ export default function TweetDetailScreen() {
const currentUser = useUserStore((s) => s.user);
- const fetchTweetData = useCallback(
- async (isRefresh = false) => {
- try {
- if (isRefresh) {
- setRefreshing(true);
- setReplies([]);
- setNextCursor(null);
- setHasMore(true);
- setError(null);
- } else {
- setLoading(true);
- setError(null);
- }
-
- const [tweetRes, repliesRes] = await Promise.all([
- getTweet(tweetId),
- getTweetReplies(tweetId),
- ]);
-
- if (tweetRes.success && tweetRes.data) {
- const tweetData = tweetRes.data;
- setMainTweet({ ...tweetData });
- setRootTweet(tweetData.rootTweet || null);
- setParentTweets(tweetData.parentTweets || []);
- setHasMoreParents(tweetData.hasMoreParents || false);
- }
-
- if (repliesRes.success && repliesRes.data) {
- setReplies(repliesRes.data.map((t) => ({ ...t })));
- setNextCursor(repliesRes.pagination?.nextCursor || null);
- setHasMore(repliesRes.pagination?.hasNextPage || false);
- }
-
- if (isRefresh) {
- setRefreshKey((prev) => prev + 1);
- }
- } catch (err) {
- const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred';
- setError(errorMessage);
- } finally {
- setLoading(false);
- setRefreshing(false);
- }
- },
- [tweetId]
- );
-
- const loadMoreReplies = useCallback(async () => {
- if (loadingMore || !hasMore || !nextCursor) return;
-
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
try {
- setLoadingMore(true);
- const response = await getTweetReplies(tweetId, { cursor: nextCursor });
-
- if (response.success && response.data) {
- setReplies((prev) => [...prev, ...response.data]);
- setNextCursor(response.pagination?.nextCursor || null);
- setHasMore(response.pagination?.hasNextPage || false);
- }
- } catch (err) {
- const errorMessage = err instanceof Error ? err.message : 'Failed to load more replies';
- setError(errorMessage);
+ await Promise.all([refetchTweet(), refetchReplies()]);
+ setRefreshKey((prev) => prev + 1);
} finally {
- setLoadingMore(false);
+ setRefreshing(false);
}
- }, [tweetId, nextCursor, hasMore, loadingMore]);
+ }, [refetchTweet, refetchReplies]);
+
+ const loadMoreReplies = useCallback(() => {
+ if (isFetchingNextPage || !hasNextPage) return;
+ fetchNextPage();
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
useEffect(() => {
- fetchTweetData();
- }, [fetchTweetData]);
+ if (initialOpenComposer && mainTweet && !hasHandledInitialComposerRef.current) {
+ hasHandledInitialComposerRef.current = true;
+ setReplyToTweetData(mainTweet);
+ setComposerVisible(true);
+ }
+ }, [initialOpenComposer, mainTweet]);
const allData = useMemo(() => {
if (!mainTweet) return [];
@@ -203,7 +192,6 @@ export default function TweetDetailScreen() {
const handleReplySubmit = useCallback(async () => {
if (!replyText.trim()) return;
try {
- setRefreshing(true);
setReplyError(null);
const convertedText = replaceTriggerValues(replyText.trim(), ({ id }) => `@${id}`);
const res = await postTweet({ content: convertedText, replyToTweetId: tweetId });
@@ -211,44 +199,56 @@ export default function TweetDetailScreen() {
setReplyText('');
setReplyInputFocused(false);
Keyboard.dismiss();
- await fetchTweetData(true);
+
+ // Update reply count in cache
+ if (mainTweet) {
+ tweetCache.incrementReplyCount(tweetId);
+ }
+
+ queryClient.invalidateQueries({ queryKey: queryKeys.tweetReplies(tweetId) });
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to post reply';
setReplyError(errorMessage);
- } finally {
- setRefreshing(false);
}
- }, [replyText, tweetId, fetchTweetData]);
-
- const handleOpenComposer = useCallback(() => {
- setReplyInputFocused(false);
- Keyboard.dismiss();
- setComposerVisible(true);
- setComposerOpenWithMedia(false);
- }, []);
+ }, [replyText, tweetId, mainTweet, tweetCache]);
+
+ const handleOpenComposer = useCallback(
+ (tweet?: Tweet) => {
+ setReplyInputFocused(false);
+ Keyboard.dismiss();
+ setReplyToTweetData(tweet || mainTweet || null);
+ setComposerVisible(true);
+ setComposerOpenWithMedia(false);
+ },
+ [mainTweet]
+ );
const handleOpenComposerWithMedia = useCallback(() => {
setReplyInputFocused(false);
Keyboard.dismiss();
+ setReplyToTweetData(mainTweet || null);
setComposerVisible(true);
setComposerOpenWithMedia(true);
- }, []);
+ }, [mainTweet]);
const handleCloseComposer = useCallback(() => {
setComposerVisible(false);
setComposerOpenWithMedia(false);
+ setReplyToTweetData(null);
setReplyInputFocused(false);
Keyboard.dismiss();
- fetchTweetData(true);
- }, [fetchTweetData]);
+ }, []);
const goToProfile = useCallback(
- (username: string) =>
- rootNavigation.navigate(ROOT.PROFILE, {
+ (username: string) => {
+ if (isViewingSameProfile(username)) return;
+
+ rootNavigation.push(ROOT.PROFILE, {
screen: PROFILE.USER_PROFILE,
params: { username },
- }),
+ });
+ },
[rootNavigation]
);
@@ -309,6 +309,7 @@ export default function TweetDetailScreen() {
onPress={handleShowMore}
style={styles.threadStyles.showMoreContainer}
testID="thread-show-more-button"
+ accessibilityLabel="thread-show-more-button"
>
{
- if (isMainTweet) return;
+ if (isMainTweet && tweetId === data.id) return;
if (tweetId === data.id) {
handleReplyPress(data);
@@ -414,7 +422,7 @@ export default function TweetDetailScreen() {
onLongPressLike={handleLongPressLike}
onLongPressRetweet={handleLongPressRetweet}
showActions={true}
- onPressReply={handleReplyPress}
+ onPressReply={() => handleOpenComposer(data)}
detailed={isMainTweet}
/>
@@ -443,6 +451,7 @@ export default function TweetDetailScreen() {
currentColors,
handleShowMore,
styles,
+ handleOpenComposer,
]
);
@@ -452,7 +461,7 @@ export default function TweetDetailScreen() {
return (
- {loadingMore && (
+ {isFetchingNextPage && (
@@ -460,7 +469,7 @@ export default function TweetDetailScreen() {
{!haveEnoughSpace && haveParentsOrRoot && }
);
- }, [loadingMore, styles, replies.length, parentTweets.length, rootTweet]);
+ }, [isFetchingNextPage, styles, replies.length, parentTweets.length, rootTweet]);
if (loading) {
return (
@@ -477,7 +486,7 @@ export default function TweetDetailScreen() {
{error}
fetchTweetData()}
+ onPress={() => handleRefresh()}
testID="retry-button"
accessibilityLabel="retry-button"
>
@@ -490,9 +499,10 @@ export default function TweetDetailScreen() {
return (
{error && mainTweet && (
@@ -518,9 +528,7 @@ export default function TweetDetailScreen() {
onEndReached={loadMoreReplies}
onEndReachedThreshold={0.5}
scrollEnabled={true}
- refreshControl={
- fetchTweetData(true)} />
- }
+ refreshControl={ }
testID="flash-list"
/>
@@ -540,7 +548,7 @@ export default function TweetDetailScreen() {
mainTweetAuthor={mainTweet?.author}
currentUser={currentUser}
theme={theme}
- onOpenComposer={handleOpenComposer}
+ onOpenComposer={() => handleOpenComposer(mainTweet || undefined)}
onOpenComposerWithMedia={handleOpenComposerWithMedia}
/>
@@ -554,16 +562,18 @@ export default function TweetDetailScreen() {
0))
+ replyToTweetData &&
+ (replyToTweetData.content ||
+ (replyToTweetData.media && replyToTweetData.media.length > 0))
? {
- content: mainTweet.content,
- author: mainTweet.author,
- media: mainTweet.media,
- createdAt: mainTweet.createdAt,
- entities: mainTweet.entities,
+ content: replyToTweetData.content,
+ author: replyToTweetData.author,
+ media: replyToTweetData.media,
+ createdAt: replyToTweetData.createdAt,
+ entities: replyToTweetData.entities,
}
: undefined
}
diff --git a/src/screens/tweets/styles.ts b/src/screens/tweets/styles.ts
index 1d3be2c0a..064da20a6 100644
--- a/src/screens/tweets/styles.ts
+++ b/src/screens/tweets/styles.ts
@@ -177,7 +177,7 @@ export const getTweetDetailScreenStyles = (theme: 'dark' | 'light', isFocused: b
top: AVATAR_SIZE + 17,
bottom: -9,
opacity: 0.3,
- zIndex: -1,
+ zIndex: 1,
},
deletedTweetContainer: {
flexDirection: 'row',
@@ -224,10 +224,10 @@ export const getTweetDetailScreenStyles = (theme: 'dark' | 'light', isFocused: b
position: 'absolute',
width: 2,
left: 9 + AVATAR_SIZE / 2,
- top: 23,
+ top: Platform.OS === 'ios' ? 23 : 26,
bottom: -8,
opacity: 0.3,
- zIndex: -1,
+ zIndex: 1,
},
});
diff --git a/src/services/explore.ts b/src/services/explore.ts
index 51961559a..72218d90d 100644
--- a/src/services/explore.ts
+++ b/src/services/explore.ts
@@ -21,7 +21,8 @@ export async function getForYouCategories(): Promise {
}
export async function getTrending(): Promise {
- return await api.get('/explore/trending');
+ const res = await api.get('/explore/trending');
+ return res;
}
export async function getNewsTrends(): Promise {
diff --git a/src/services/onBoarding.ts b/src/services/onBoarding.ts
index 75689bdcf..18ea857f7 100644
--- a/src/services/onBoarding.ts
+++ b/src/services/onBoarding.ts
@@ -1,6 +1,8 @@
+import { FollowUserSuggestion } from '@/types/user';
+
import api from '../libs/api';
-export type OnBoardingStatusResponse = {
+export type OnBoardingUsernameResponse = {
success: boolean;
data: {
suggestions: string[];
@@ -8,12 +10,22 @@ export type OnBoardingStatusResponse = {
message?: string;
};
-export async function fetchUsernameSuggestions(typed?: string): Promise {
+export type OnBoardingFollowResponse = {
+ success: boolean;
+ data: {
+ suggestions: FollowUserSuggestion[];
+ };
+ message?: string;
+};
+
+export async function fetchUsernameSuggestions(
+ typed?: string
+): Promise {
try {
const endpoint = typed
? `/onboarding/username-suggestions?typed=${encodeURIComponent(typed)}`
: '/onboarding/username-suggestions';
- const response = await api.get(endpoint);
+ const response = await api.get(endpoint);
return response;
} catch {
return {
@@ -23,3 +35,16 @@ export async function fetchUsernameSuggestions(typed?: string): Promise {
+ try {
+ const response = await api.get('/onboarding/follow-suggestions');
+ return response;
+ } catch {
+ return {
+ success: false,
+ data: { suggestions: [] },
+ message: 'Failed to fetch follow suggestions.',
+ };
+ }
+}
diff --git a/src/services/search.ts b/src/services/search.ts
index 4f9588d1d..b5708001e 100644
--- a/src/services/search.ts
+++ b/src/services/search.ts
@@ -1,12 +1,7 @@
import api, { ApiSuccessResponse } from '@/libs/api';
import type { TimelineFeedResponse } from '@/services/timeline';
-import type {
- SearchPeopleFilter,
- SearchTab,
- SearchUser,
- TopHashtagsResponse,
-} from '@/types/search';
+import type { SearchPeopleFilter, SearchSuggestion, SearchTab, SearchUser } from '@/types/search';
type SearchUsersParams = {
query: string;
@@ -20,7 +15,6 @@ export type SearchUsersResult = ApiSuccessResponse<{ users: SearchUser[] }>;
export const searchKeys = {
suggestions: (query: string) => ['search', 'suggestions', query] as const,
- hashtags: (query: string) => ['search', 'hashtags', query] as const,
tweets: (
query: string,
tab: SearchTab,
@@ -90,10 +84,10 @@ export async function searchTweets({
return api.get(`/search/tweets?${params.toString()}`);
}
-export async function searchTopHashtags(query: string) {
+export async function searchSuggestions(query: string) {
const params = new URLSearchParams({ query });
- return api.get>(
- `/search/hashtags/top?${params.toString()}`
+ return api.get>(
+ `/search/suggestions?${params.toString()}`
);
}
diff --git a/src/services/socket.ts b/src/services/socket.ts
index 8f3ce17ec..4aab53f60 100644
--- a/src/services/socket.ts
+++ b/src/services/socket.ts
@@ -31,14 +31,6 @@ export function initSocket(token: string) {
return socket;
}
-export function joinConversation(id: string) {
- socket?.emit('join_conversation', id);
-}
-
-export function leaveConversation(id: string) {
- socket?.emit('leave_conversation', id);
-}
-
export function sendMessage(
conversationId: string,
content: string,
diff --git a/src/services/tweets.ts b/src/services/tweets.ts
index 4e14b947e..38fe33a12 100644
--- a/src/services/tweets.ts
+++ b/src/services/tweets.ts
@@ -1,5 +1,5 @@
import api, { ApiException, ApiSuccessResponse } from '@/libs/api';
-import { UserRelationship } from '@/types/user';
+import { CompactUser, UserRelationship } from '@/types/user';
export async function likeTweet(id: string) {
try {
@@ -98,9 +98,7 @@ export type UserMetadata = {
username: string;
displayName: string;
avatarUrl: string | null;
- isFollowing?: boolean | null;
- isFollower?: boolean | null;
- relationship?: UserRelationship;
+ relationship: UserRelationship;
};
type DeletedTweet = {
@@ -155,8 +153,11 @@ export type CursorPaginationParameters = {
};
export type UserMetadataWithFollowInfo = UserMetadata & {
- isFollowing: boolean | null;
- isFollower: boolean | null;
+ isFollowing: boolean;
+ isFollower: boolean;
+ isBlocked: boolean;
+ isMuted: boolean;
+ bio: UserBio | null;
};
export type UserBio = {
@@ -167,12 +168,6 @@ export type UserBio = {
} | null;
};
-export type UserMetadataWithBio = UserMetadataWithFollowInfo & {
- bio: UserBio | null;
- isBlocked: boolean;
- isMuted: boolean;
-};
-
export type UploadImageResponse = {
success: true;
data: {
@@ -194,9 +189,7 @@ export type UploadVideoResponse = {
export type PostTweetResponse = {
success: true;
message: string;
- data: {
- id: string;
- };
+ data: Tweet;
};
export type GetTweetResponse = {
@@ -218,13 +211,13 @@ export type GetTweetQuotesResponse = {
export type GetTweetRetweetersResponse = {
success: true;
- data: UserMetadataWithBio[];
+ data: CompactUser[];
pagination: CursorPagination;
};
export type GetTweetLikesResponse = {
success: true;
- data: UserMetadataWithFollowInfo[];
+ data: CompactUser[];
pagination: CursorPagination;
};
diff --git a/src/services/user.ts b/src/services/user.ts
new file mode 100644
index 000000000..f57f5ed45
--- /dev/null
+++ b/src/services/user.ts
@@ -0,0 +1,25 @@
+import { UserProfile } from '@/types/user';
+
+import api, { ApiSuccessResponse } from '../libs/api';
+
+export async function getUserProfile(username: string) {
+ const response = await api.get>(
+ `/users/${encodeURIComponent(username)}/profile`
+ );
+ return response;
+}
+
+export async function followUser(username: string) {
+ const response = await api.post>(
+ `/users/${encodeURIComponent(username)}/following`,
+ undefined
+ );
+ return response;
+}
+
+export async function unfollowUser(username: string) {
+ const response = await api.delete>(
+ `/users/${encodeURIComponent(username)}/following`
+ );
+ return response;
+}
diff --git a/src/services/users.ts b/src/services/users.ts
index 33f1a97e9..e3c522dee 100644
--- a/src/services/users.ts
+++ b/src/services/users.ts
@@ -1,5 +1,5 @@
import { ProfileTweet, Tweet } from '@/types/tweet';
-import { FollowingUser } from '@/types/user';
+import { CompactUser } from '@/types/user';
import api, { CursorPagination } from '../libs/api';
@@ -34,7 +34,7 @@ export interface GetUserLikesResponse {
export interface GetUserMutualsResponse {
success: boolean;
message?: string;
- data: FollowingUser[];
+ data: CompactUser[];
pagination: CursorPagination;
}
@@ -54,6 +54,7 @@ export async function getUserTweets(
);
if (!response.success) {
+ console.error('Error fetching tweets for', username, ':', response.message);
throw new Error(response.message || 'Failed to load tweets');
}
diff --git a/src/stores/timelineStore.ts b/src/stores/timelineStore.ts
new file mode 100644
index 000000000..0275c103f
--- /dev/null
+++ b/src/stores/timelineStore.ts
@@ -0,0 +1,13 @@
+import { create } from 'zustand';
+
+type TimelineState = {
+ followingNewTweetAuthors: string[] | null;
+ setFollowingNewTweetAuthors: (authors: string[] | null) => void;
+ clearFollowingNewTweetAuthors: () => void;
+};
+
+export const useTimelineStore = create((set) => ({
+ followingNewTweetAuthors: null,
+ setFollowingNewTweetAuthors: (authors) => set({ followingNewTweetAuthors: authors }),
+ clearFollowingNewTweetAuthors: () => set({ followingNewTweetAuthors: null }),
+}));
diff --git a/src/types/dm.ts b/src/types/dm.ts
index ca2b2e0fc..82895cc2e 100644
--- a/src/types/dm.ts
+++ b/src/types/dm.ts
@@ -43,6 +43,7 @@ export type Message = {
sender: MessageReaction;
receiver: MessageReaction;
};
+ failed?: boolean;
};
export type SendMessageRequest = {
diff --git a/src/types/explore.ts b/src/types/explore.ts
index e02bd9103..2e13ce578 100644
--- a/src/types/explore.ts
+++ b/src/types/explore.ts
@@ -1,5 +1,5 @@
export type Trend = {
hashtag: string;
- tweetCount: number;
+ tweetsCount: number;
category: string;
};
diff --git a/src/types/navigation.ts b/src/types/navigation.ts
index 5b3eb28c4..8ac45d47b 100644
--- a/src/types/navigation.ts
+++ b/src/types/navigation.ts
@@ -51,11 +51,13 @@ export type RootStackParamList = {
};
[ROOT.SEARCH_FILTERS]: undefined;
[ROOT.TWEET]: NavigatorScreenParams;
- [ROOT.TWEET_DETAIL]: { tweetId: string };
+ [ROOT.TWEET_DETAIL]: { tweetId: string; initialOpenComposer?: boolean };
+ [ROOT.TERMS_OF_SERVICE]: undefined;
+ [ROOT.PRIVACY_POLICY]: undefined;
};
export type TweetStackParamList = {
- [TWEET.DETAIL]: { tweetId: string };
+ [TWEET.DETAIL]: { tweetId: string; initialOpenComposer?: boolean };
[TWEET.ACTIVITY]: { tweetId: string; initialTab?: 'Likes' | 'Reposts' | 'Quotes' };
};
@@ -100,7 +102,6 @@ export type SettingsStackParamList = {
[SETTINGS.MAIN]: undefined;
[SETTINGS.ACCOUNT]: NavigatorScreenParams;
[SETTINGS.PRIVACY]: NavigatorScreenParams;
- [SETTINGS.NOTIFICATIONS]: undefined;
[SETTINGS.TIMELINE]: undefined;
[SETTINGS.APPEARANCE]: undefined;
};
@@ -138,6 +139,7 @@ export type ProfileSetupStackParamList = {
profilePicture: ImageAsset | null;
interests?: string[];
};
+ [PROFILE_SETUP.FOLLOW_SUGGESTIONS]: undefined;
};
export type OAuthCompleteParamList = { creationToken: string };
@@ -148,7 +150,6 @@ export type DrawerParamList = {
[DRAWER.SETTINGS]: undefined;
[DRAWER.PROFILE]: NavigatorScreenParams;
[DRAWER.MESSAGES]: NavigatorScreenParams;
- [DRAWER.FOLLOWER_REQUESTS]: undefined;
};
export type BottomTabsParamList = {
diff --git a/src/types/notifications.ts b/src/types/notifications.ts
index 94e482ae1..90e2fb49b 100644
--- a/src/types/notifications.ts
+++ b/src/types/notifications.ts
@@ -36,7 +36,7 @@ export interface PreviewActor {
username: string;
displayName: string;
avatarUrl: string;
- isFollowing: boolean;
+ isFollowing?: boolean;
}
interface BaseNotification {
@@ -82,7 +82,7 @@ export type MarkNotificationAsSeenResponse = ApiResponse<{
export interface ApiResponse {
success: boolean;
- message: string;
+ message?: string;
data: T;
}
diff --git a/src/types/search.ts b/src/types/search.ts
index 477469128..4ad7ab4f3 100644
--- a/src/types/search.ts
+++ b/src/types/search.ts
@@ -1,14 +1,13 @@
import { CursorPagination } from './cursor';
import { Tweet } from './tweet';
-import { UserMetaData, UserRelationship } from './user';
+import { CompactUser } from './user';
export type SearchTab = 'top' | 'latest' | 'media';
export type SearchResultTab = SearchTab | 'people';
export type SearchPeopleFilter = 'anyone' | 'following';
-export type SearchUser = UserMetaData & {
- bio: string | null;
- relationship: UserRelationship | null;
+export type SearchUser = CompactUser & {
+ bannerUrl: string | null;
};
export type SearchUsersResponse = {
@@ -20,11 +19,6 @@ export type SearchTweetsResponse = {
pagination: CursorPagination;
};
-export type SearchHashtag = {
- hashtag: string;
- usageCount: number;
-};
+export type SearchSuggestion = string;
-export type TopHashtagsResponse = {
- hashtags: SearchHashtag[];
-};
+export type SearchSuggestionCategory = 'hashtag' | 'topic';
diff --git a/src/types/tweet.ts b/src/types/tweet.ts
index 7600a9fc2..671d9dc63 100644
--- a/src/types/tweet.ts
+++ b/src/types/tweet.ts
@@ -29,9 +29,7 @@ export interface UserMetadata {
username: string;
displayName: string;
avatarUrl: string | null;
- relationship?: UserRelationship;
- isFollowing?: boolean | null;
- isFollower?: boolean | null;
+ relationship: UserRelationship;
}
export interface PostTweetPayload {
diff --git a/src/types/user.ts b/src/types/user.ts
index cf5151af1..2c32bd880 100644
--- a/src/types/user.ts
+++ b/src/types/user.ts
@@ -23,8 +23,8 @@ export type UserRelationship = {
blocking?: boolean;
blockedBy?: boolean;
muted?: boolean;
- following?: boolean | null;
- follower?: boolean | null;
+ following?: boolean;
+ follower?: boolean;
};
export type HashtagEntity = {
@@ -75,13 +75,7 @@ export type CompactUser = {
bio: string | null;
avatarUrl: string | null;
bioEntities: BioEntities | null;
- relationship?: UserRelationship;
-};
-
-export type FollowingUser = CompactUser & {
- isFollowing: boolean;
- followsYou: boolean;
- isBlocked: boolean;
+ relationship: UserRelationship;
};
export type UserMetaData = {
@@ -100,13 +94,20 @@ export type GetUserProfileResponse = {
export type GetUserFollowersResponse = {
success: boolean;
message?: string;
- data: FollowingUser[];
+ data: CompactUser[];
pagination: CursorPagination;
};
export type GetUserFollowingResponse = {
success: boolean;
message?: string;
- data: FollowingUser[];
+ data: CompactUser[];
pagination: CursorPagination;
};
+
+export type FollowUserSuggestion = {
+ username: string;
+ displayName: string;
+ avatarUrl?: string;
+ bio?: string;
+};
diff --git a/src/utils/navigation/isViewingSameProfile.ts b/src/utils/navigation/isViewingSameProfile.ts
new file mode 100644
index 000000000..7158f211b
--- /dev/null
+++ b/src/utils/navigation/isViewingSameProfile.ts
@@ -0,0 +1,18 @@
+import { navigationRef } from '@/navigation/navigationRef';
+
+import { PROFILE_TABS } from './routeNames';
+
+export const isViewingSameProfile = (username?: string | null): boolean => {
+ if (!username) return false;
+ if (!navigationRef.isReady()) return false;
+ if (!username.trim().toLowerCase()) return false;
+
+ const currentRoute = navigationRef.getCurrentRoute();
+ if (!currentRoute || !(Object.values(PROFILE_TABS) as string[]).includes(currentRoute.name))
+ return false;
+
+ const currentUsername = (currentRoute.params as { username?: string })?.username;
+ if (!currentUsername) return false;
+
+ return currentUsername.trim().toLowerCase() === username.trim().toLowerCase();
+};
diff --git a/src/utils/navigation/routeNames.ts b/src/utils/navigation/routeNames.ts
index 675ec785b..445b78af2 100644
--- a/src/utils/navigation/routeNames.ts
+++ b/src/utils/navigation/routeNames.ts
@@ -12,6 +12,8 @@ export const ROOT = {
SEARCH: 'Search',
SEARCH_FILTERS: 'SearchFilters',
TWEET_DETAIL: 'RootTweetDetail',
+ TERMS_OF_SERVICE: 'TermsOfService',
+ PRIVACY_POLICY: 'PrivacyPolicy',
} as const;
export const AUTH = {
@@ -37,6 +39,7 @@ export const PROFILE_SETUP = {
PHOTO: 'Photo',
BIO: 'Bio',
INTERESTS: 'Interests',
+ FOLLOW_SUGGESTIONS: 'FollowSuggestions',
} as const;
export const RESET_PASSWORD = {
@@ -61,7 +64,7 @@ export const PROFILE_TABS = {
FOLLOWING: 'Following',
FOLLOWERS: 'Followers',
MUTUALS: 'Followers you know',
-};
+} as const;
export const DRAWER = {
BOTTOM_TABS: 'BottomTabs',
diff --git a/src/utils/updateSearchCache.ts b/src/utils/updateSearchCache.ts
new file mode 100644
index 000000000..1ad2b6715
--- /dev/null
+++ b/src/utils/updateSearchCache.ts
@@ -0,0 +1,106 @@
+import { InfiniteData, QueryClient } from '@tanstack/react-query';
+
+import type { SearchUsersResult } from '@/services/search';
+import type { UserRelationship } from '@/types/user';
+
+type SearchUsersInfiniteData = InfiniteData;
+
+export function updateSearchUsersCache(
+ queryClient: QueryClient,
+ username: string,
+ relationshipUpdate: Partial
+) {
+ const searchResultsQueries = queryClient.getQueriesData({
+ predicate: (query) => {
+ const key = query.queryKey;
+ return (
+ Array.isArray(key) && key[0] === 'search' && key[1] === 'people' && key[2] === 'results'
+ );
+ },
+ });
+
+ searchResultsQueries.forEach(([queryKey, data]) => {
+ if (!data) return;
+
+ const pages = data.pages.map((page) => ({
+ ...page,
+ data: {
+ ...page.data,
+ users: page.data.users.map((user) =>
+ user.username === username
+ ? {
+ ...user,
+ relationship: user.relationship
+ ? { ...user.relationship, ...relationshipUpdate }
+ : { ...relationshipUpdate },
+ }
+ : user
+ ),
+ },
+ }));
+
+ queryClient.setQueryData(queryKey, { ...data, pages });
+ });
+
+ const suggestionsQueries = queryClient.getQueriesData({
+ predicate: (query) => {
+ const key = query.queryKey;
+ return (
+ Array.isArray(key) && key[0] === 'search' && key[1] === 'people' && key[2] === 'suggestions'
+ );
+ },
+ });
+
+ suggestionsQueries.forEach(([queryKey, data]) => {
+ if (!data?.data?.users) return;
+
+ const updatedData = {
+ ...data,
+ data: {
+ ...data.data,
+ users: data.data.users.map((user) =>
+ user.username === username
+ ? {
+ ...user,
+ relationship: user.relationship
+ ? { ...user.relationship, ...relationshipUpdate }
+ : { ...relationshipUpdate },
+ }
+ : user
+ ),
+ },
+ };
+
+ queryClient.setQueryData(queryKey, updatedData);
+ });
+
+ const topPeopleQueries = queryClient.getQueriesData({
+ predicate: (query) => {
+ const key = query.queryKey;
+ return Array.isArray(key) && key[0] === 'search' && key[1] === 'people' && key[2] === 'top';
+ },
+ });
+
+ topPeopleQueries.forEach(([queryKey, data]) => {
+ if (!data?.data?.users) return;
+
+ const updatedData = {
+ ...data,
+ data: {
+ ...data.data,
+ users: data.data.users.map((user) =>
+ user.username === username
+ ? {
+ ...user,
+ relationship: user.relationship
+ ? { ...user.relationship, ...relationshipUpdate }
+ : { ...relationshipUpdate },
+ }
+ : user
+ ),
+ },
+ };
+
+ queryClient.setQueryData(queryKey, updatedData);
+ });
+}