From 1115c4d8a8e6c21397bcd839a8ef73ed24d043a0 Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Fri, 20 Feb 2026 22:38:29 +0000 Subject: [PATCH] mastadon mobile --- ushadow/backend/main.py | 7 +- ushadow/backend/requirements.txt | 3 + ushadow/backend/src/models/feed.py | 21 + ushadow/backend/src/routers/feed.py | 58 + ushadow/backend/src/services/feed_service.py | 48 + .../backend/src/services/mastodon_oauth.py | 132 +++ .../src/services/platforms/mastodon.py | 48 +- ushadow/backend/src/services/post_fetcher.py | 1 + ushadow/mobile/app/(tabs)/_layout.tsx | 17 +- ushadow/mobile/app/(tabs)/feeds.tsx | 1007 +++++++++++++++++ ushadow/mobile/app/(tabs)/index.tsx | 12 +- .../components/LoginScreenWithKeycloak.tsx | 90 +- ushadow/mobile/app/services/feedApi.ts | 212 ++++ ushadow/mobile/app/unode-details.tsx | 7 +- ushadow/mobile/package-lock.json | 22 + 15 files changed, 1639 insertions(+), 46 deletions(-) create mode 100644 ushadow/backend/src/services/mastodon_oauth.py create mode 100644 ushadow/mobile/app/(tabs)/feeds.tsx create mode 100644 ushadow/mobile/app/services/feedApi.ts diff --git a/ushadow/backend/main.py b/ushadow/backend/main.py index 3717187e..5c244e2c 100644 --- a/ushadow/backend/main.py +++ b/ushadow/backend/main.py @@ -20,7 +20,7 @@ from src.models.user import User # Beanie document model from src.models.share import ShareToken # Beanie document model -from src.models.feed import PostSource, Post # Beanie document model +from src.models.feed import PostSource, Post, MastodonAppCredential # Beanie document models from src.routers import health, wizard, chronicle, auth, feature_flags from src.routers import services, deployments, providers, service_configs, chat @@ -134,7 +134,10 @@ def send_telemetry(): app.state.db = db # Initialize Beanie ODM with document models - await init_beanie(database=db, document_models=[User, ShareToken, PostSource, Post]) + await init_beanie( + database=db, + document_models=[User, ShareToken, PostSource, Post, MastodonAppCredential], + ) logger.info("✓ Beanie ODM initialized") # Create admin user if explicitly configured in secrets.yaml diff --git a/ushadow/backend/requirements.txt b/ushadow/backend/requirements.txt index c473f074..68359ad6 100644 --- a/ushadow/backend/requirements.txt +++ b/ushadow/backend/requirements.txt @@ -10,6 +10,9 @@ email-validator==2.2.0 # HTTP Client for Chronicle/MCP/Agent Zero httpx==0.27.2 + +# Mastodon / ActivityPub client (OAuth2 + REST API) +Mastodon.py>=1.8.0 aiohttp==3.11.7 # LLM Integration - Unified API for multiple providers diff --git a/ushadow/backend/src/models/feed.py b/ushadow/backend/src/models/feed.py index 02026537..ccf54057 100644 --- a/ushadow/backend/src/models/feed.py +++ b/ushadow/backend/src/models/feed.py @@ -39,6 +39,11 @@ class PostSource(Document): api_key: Optional[str] = Field( default=None, description="API key (required for youtube)" ) + # Mastodon OAuth2 — when set, fetches from the user's authenticated home timeline + # instead of public hashtag timelines. + access_token: Optional[str] = Field( + default=None, description="Mastodon OAuth2 access token" + ) enabled: bool = Field(default=True) created_at: datetime = Field(default_factory=datetime.utcnow) @@ -52,6 +57,22 @@ class Settings: ] +class MastodonAppCredential(Document): + """Cached OAuth2 app credentials per Mastodon instance. + + Mastodon requires registering an application before starting OAuth. + We register once per instance URL and cache the client_id / client_secret. + """ + + instance_url: str = Field(..., description="Normalised instance base URL") + client_id: str + client_secret: str + + class Settings: + name = "mastodon_app_credentials" + indexes = ["instance_url"] + + class Post(Document): """A content item from any platform, scored against the user's interests.""" diff --git a/ushadow/backend/src/routers/feed.py b/ushadow/backend/src/routers/feed.py index 16ffaef7..c0889dfd 100644 --- a/ushadow/backend/src/routers/feed.py +++ b/ushadow/backend/src/routers/feed.py @@ -11,6 +11,14 @@ from src.database import get_database from src.models.feed import SourceCreate +from pydantic import BaseModel + + +class MastodonConnectRequest(BaseModel): + instance_url: str + code: str + redirect_uri: str + name: str = "Mastodon" from src.services.auth import get_current_user from src.services.feed_service import FeedService from src.utils.auth_helpers import get_user_email @@ -42,6 +50,56 @@ async def list_sources( return {"sources": sources} +@router.get("/sources/mastodon/auth-url") +async def mastodon_auth_url( + instance_url: str = Query(..., description="Mastodon instance URL, e.g. mastodon.social"), + redirect_uri: str = Query(..., description="App redirect URI for OAuth callback"), + service: FeedService = Depends(get_feed_service), + current_user=Depends(get_current_user), +): + """Return a Mastodon OAuth2 authorization URL. + + The client should open this URL in a browser. After the user authorises, + Mastodon redirects to redirect_uri?code=. Pass that code to + POST /api/feed/sources/mastodon/connect. + """ + try: + url = await service.get_mastodon_auth_url(instance_url, redirect_uri) + return {"authorization_url": url} + except Exception as e: + logger.error(f"Mastodon auth URL error: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + +@router.post("/sources/mastodon/connect", status_code=201) +async def mastodon_connect( + data: MastodonConnectRequest, + service: FeedService = Depends(get_feed_service), + current_user=Depends(get_current_user), +): + """Exchange a Mastodon OAuth2 code for an access token and save the source. + + Creates a new PostSource (or updates an existing one for the same instance) + with the access token. Future refreshes will pull from the authenticated + home timeline instead of public hashtag timelines. + """ + user_id = get_user_email(current_user) + try: + source = await service.connect_mastodon( + user_id=user_id, + instance_url=data.instance_url, + code=data.code, + redirect_uri=data.redirect_uri, + name=data.name, + ) + return source + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Mastodon connect error: {e}") + raise HTTPException(status_code=502, detail=str(e)) + + @router.post("/sources", status_code=201) async def add_source( data: SourceCreate, diff --git a/ushadow/backend/src/services/feed_service.py b/ushadow/backend/src/services/feed_service.py index d6ad6aad..4bf52b31 100644 --- a/ushadow/backend/src/services/feed_service.py +++ b/ushadow/backend/src/services/feed_service.py @@ -17,6 +17,7 @@ SourceCreate, ) from src.services.interest_extractor import InterestExtractor +from src.services.mastodon_oauth import MastodonOAuthService from src.services.post_fetcher import PostFetcher from src.services.post_scorer import PostScorer @@ -55,6 +56,53 @@ async def list_sources(self, user_id: str) -> List[PostSource]: """List all configured post sources for a user.""" return await PostSource.find(PostSource.user_id == user_id).to_list() + async def get_mastodon_auth_url( + self, instance_url: str, redirect_uri: str + ) -> str: + """Register app (or reuse cached) and return Mastodon authorization URL.""" + oauth = MastodonOAuthService() + return await oauth.get_authorization_url(instance_url, redirect_uri) + + async def connect_mastodon( + self, + user_id: str, + instance_url: str, + code: str, + redirect_uri: str, + name: str, + ) -> PostSource: + """Exchange OAuth code for a token and create/update a Mastodon source. + + If a source already exists for this user + instance, the token is + refreshed in-place. Otherwise a new PostSource is created. + """ + oauth = MastodonOAuthService() + access_token = await oauth.exchange_code(instance_url, code, redirect_uri) + + normalised_url = instance_url.rstrip("/") + existing = await PostSource.find_one( + PostSource.user_id == user_id, + PostSource.platform_type == "mastodon", + PostSource.instance_url == normalised_url, + ) + if existing: + existing.access_token = access_token + existing.name = name + await existing.save() + logger.info(f"Updated Mastodon token for {user_id} on {normalised_url}") + return existing + + source = PostSource( + user_id=user_id, + name=name, + platform_type="mastodon", + instance_url=normalised_url, + access_token=access_token, + ) + await source.insert() + logger.info(f"Connected Mastodon account for {user_id} on {normalised_url}") + return source + async def remove_source(self, user_id: str, source_id: str) -> bool: """Remove a post source.""" source = await PostSource.find_one( diff --git a/ushadow/backend/src/services/mastodon_oauth.py b/ushadow/backend/src/services/mastodon_oauth.py new file mode 100644 index 00000000..43e6deb1 --- /dev/null +++ b/ushadow/backend/src/services/mastodon_oauth.py @@ -0,0 +1,132 @@ +"""Mastodon OAuth2 service — app registration and token exchange. + +Uses Mastodon.py for the OAuth dance (app registration, URL generation, +code exchange). The library's synchronous calls are run via asyncio.to_thread +so they don't block the event loop. + +Flow: + 1. GET /api/feed/sources/mastodon/auth-url?instance_url=...&redirect_uri=... + → registers app (or reuses cached credentials) + → returns authorization URL to open in-browser + 2. User authorises → redirect to redirect_uri?code=xxx + 3. POST /api/feed/sources/mastodon/connect + { instance_url, code, redirect_uri, name } + → exchanges code for access_token + → saves PostSource with token +""" + +import asyncio +import logging + +from mastodon import Mastodon + +from src.models.feed import MastodonAppCredential + +logger = logging.getLogger(__name__) + +_SCOPES = ["read"] +_APP_NAME = "Ushadow" + + +class MastodonOAuthService: + """Handles OAuth2 registration and token exchange with Mastodon instances.""" + + async def get_authorization_url( + self, instance_url: str, redirect_uri: str + ) -> str: + """Return the Mastodon authorization URL for the given instance. + + Registers an OAuth2 app on the instance if not already cached. + """ + instance_url = _normalise(instance_url) + cred = await self._get_or_register_app(instance_url, redirect_uri) + + def _build() -> str: + m = Mastodon( + client_id=cred.client_id, + client_secret=cred.client_secret, + api_base_url=instance_url, + ) + return m.auth_request_url( + redirect_uris=redirect_uri, + scopes=_SCOPES, + ) + + url: str = await asyncio.to_thread(_build) + logger.info(f"Generated Mastodon auth URL for {instance_url}") + return url + + async def exchange_code( + self, instance_url: str, code: str, redirect_uri: str + ) -> str: + """Exchange an authorization code for an access token. + + Returns: + The access token string. + + Raises: + ValueError: If no app is registered for this instance. + """ + instance_url = _normalise(instance_url) + cred = await MastodonAppCredential.find_one( + MastodonAppCredential.instance_url == instance_url + ) + if not cred: + raise ValueError( + f"No app registered for {instance_url}. " + "Call get_authorization_url first." + ) + + def _exchange() -> str: + m = Mastodon( + client_id=cred.client_id, + client_secret=cred.client_secret, + api_base_url=instance_url, + ) + token: str = m.log_in( + code=code, + redirect_uri=redirect_uri, + scopes=_SCOPES, + ) + return token + + token = await asyncio.to_thread(_exchange) + logger.info(f"Exchanged OAuth code for token on {instance_url}") + return token + + async def _get_or_register_app( + self, instance_url: str, redirect_uri: str + ) -> MastodonAppCredential: + """Return cached credentials, or register a new app on the instance.""" + existing = await MastodonAppCredential.find_one( + MastodonAppCredential.instance_url == instance_url + ) + if existing: + return existing + + def _register() -> tuple[str, str]: + return Mastodon.create_app( + _APP_NAME, + api_base_url=instance_url, + redirect_uris=redirect_uri, + scopes=_SCOPES, + to_file=None, + ) + + client_id, client_secret = await asyncio.to_thread(_register) + cred = MastodonAppCredential( + instance_url=instance_url, + client_id=client_id, + client_secret=client_secret, + ) + await cred.insert() + logger.info(f"Registered Mastodon OAuth2 app for {instance_url}") + return cred + + +def _normalise(url: str) -> str: + """Ensure consistent URL format (https, no trailing slash).""" + url = url.strip().rstrip("/") + if not url.startswith(("http://", "https://")): + url = f"https://{url}" + return url diff --git a/ushadow/backend/src/services/platforms/mastodon.py b/ushadow/backend/src/services/platforms/mastodon.py index bf0d8af3..9f7f9e04 100644 --- a/ushadow/backend/src/services/platforms/mastodon.py +++ b/ushadow/backend/src/services/platforms/mastodon.py @@ -28,14 +28,25 @@ class MastodonFetcher(PlatformFetcher): async def fetch_for_interests( self, interests: List[Interest], config: Dict[str, Any] ) -> List[Dict[str, Any]]: - """Fetch posts for all interest hashtags from a Mastodon instance. + """Fetch posts from Mastodon. + + If an OAuth access_token is configured, fetches from the user's + authenticated home timeline (all accounts they follow). + Otherwise falls back to public hashtag timelines derived from + the user's interests. Args: interests: User interests with derived hashtags. - config: Must contain 'instance_url'. + config: Must contain 'instance_url'; optionally 'access_token'. """ instance_url = config["instance_url"] + access_token = config.get("access_token") or "" + source_id = config.get("source_id", "") + + if access_token: + return await _fetch_home_timeline(instance_url, access_token, source_id) + # Unauthenticated fallback: public hashtag timelines hashtags = _collect_hashtags(interests, MAX_HASHTAGS) if not hashtags: return [] @@ -44,17 +55,13 @@ async def fetch_for_interests( async def _bounded_fetch(hashtag: str) -> List[Dict[str, Any]]: async with semaphore: - return await _fetch_hashtag_timeline( - instance_url, hashtag - ) + return await _fetch_hashtag_timeline(instance_url, hashtag) tasks = [_bounded_fetch(tag) for tag in hashtags] results = await asyncio.gather(*tasks, return_exceptions=True) - # Flatten and deduplicate by Mastodon URI seen_ids: Set[str] = set() posts: List[Dict[str, Any]] = [] - source_id = config.get("source_id", "") for result in results: if isinstance(result, Exception): @@ -70,7 +77,7 @@ async def _bounded_fetch(hashtag: str) -> List[Dict[str, Any]]: logger.info( f"Fetched {len(posts)} unique posts from {instance_url} " - f"({len(tasks)} requests)" + f"({len(tasks)} hashtag requests)" ) return posts @@ -139,6 +146,31 @@ def _collect_hashtags(interests: List[Interest], max_count: int) -> List[str]: return hashtags +async def _fetch_home_timeline( + instance_url: str, access_token: str, source_id: str +) -> List[Dict[str, Any]]: + """Fetch the authenticated user's home timeline (accounts they follow).""" + url = f"{instance_url.rstrip('/')}/api/v1/timelines/home" + headers = {"Authorization": f"Bearer {access_token}"} + try: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + url, headers=headers, params={"limit": DEFAULT_LIMIT} + ) + resp.raise_for_status() + statuses = resp.json() + for s in statuses: + s["_source_id"] = source_id + s["_source_instance"] = instance_url + logger.debug( + f"Fetched {len(statuses)} posts from home timeline at {instance_url}" + ) + return statuses + except httpx.HTTPError as e: + logger.warning(f"Failed to fetch home timeline from {instance_url}: {e}") + return [] + + async def _fetch_hashtag_timeline( instance_url: str, hashtag: str ) -> List[Dict[str, Any]]: diff --git a/ushadow/backend/src/services/post_fetcher.py b/ushadow/backend/src/services/post_fetcher.py index c5cd459c..439a7c1d 100644 --- a/ushadow/backend/src/services/post_fetcher.py +++ b/ushadow/backend/src/services/post_fetcher.py @@ -81,5 +81,6 @@ def _source_to_config(source: PostSource) -> Dict[str, Any]: "source_id": source.source_id, "instance_url": source.instance_url or "", "api_key": source.api_key or "", + "access_token": source.access_token or "", "platform_type": source.platform_type, } diff --git a/ushadow/mobile/app/(tabs)/_layout.tsx b/ushadow/mobile/app/(tabs)/_layout.tsx index 89fdc2b1..ffe93f23 100644 --- a/ushadow/mobile/app/(tabs)/_layout.tsx +++ b/ushadow/mobile/app/(tabs)/_layout.tsx @@ -1,7 +1,7 @@ /** * Tab Layout for Ushadow Mobile * - * Bottom tab navigation with Home, Conversations, and Memories tabs. + * Bottom tab navigation with Home, Chat, Feed, History, and Memories tabs. * Uses safe area insets to avoid being cut off by home indicator. */ @@ -64,6 +64,21 @@ export default function TabLayout() { tabBarAccessibilityLabel: 'Chat Tab', }} /> + ( + + ), + tabBarAccessibilityLabel: 'Feed Tab', + }} + /> = { + social: 'mastodon', + videos: 'youtube', +}; + +export default function FeedsScreen() { + const [activeTab, setActiveTab] = useState('social'); + const [posts, setPosts] = useState([]); + const [interests, setInterests] = useState([]); + const [selectedInterest, setSelectedInterest] = useState(); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isRefreshingFeed, setIsRefreshingFeed] = useState(false); + const [error, setError] = useState(null); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [sources, setSources] = useState([]); + const [instanceUrlInput, setInstanceUrlInput] = useState('mastodon.social'); + const [isConnecting, setIsConnecting] = useState(false); + + const platformType = TAB_PLATFORM[activeTab]; + + // ─── Data Loading ──────────────────────────────────────────────────── + + const loadData = useCallback( + async (opts?: { showRefresh?: boolean; pageNum?: number }) => { + try { + if (opts?.showRefresh) setIsRefreshing(true); + setError(null); + + const loggedIn = await isAuthenticated(); + setIsLoggedIn(loggedIn); + if (!loggedIn) { + setPosts([]); + setInterests([]); + return; + } + + const currentPage = opts?.pageNum ?? page; + + const [feedData, interestData, sourcesData] = await Promise.all([ + fetchFeedPosts({ + page: currentPage, + page_size: 20, + interest: selectedInterest, + platform_type: platformType, + }), + fetchFeedInterests(), + fetchFeedSources(), + ]); + + setPosts(feedData.posts); + setTotal(feedData.total); + setTotalPages(feedData.total_pages); + setInterests(interestData); + setSources(sourcesData); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load feed'; + setError(message); + console.error('[Feeds] Error:', err); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, + [page, selectedInterest, platformType], + ); + + // Reload on focus + useFocusEffect( + useCallback(() => { + setIsLoading(true); + loadData(); + }, [loadData]), + ); + + const handlePullRefresh = useCallback(() => { + loadData({ showRefresh: true }); + }, [loadData]); + + const handleRefreshFeed = useCallback(async () => { + try { + setIsRefreshingFeed(true); + await refreshFeed(platformType); + setPage(1); + await loadData({ pageNum: 1 }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Refresh failed'); + } finally { + setIsRefreshingFeed(false); + } + }, [platformType, loadData]); + + const handleTabChange = useCallback( + (tab: FeedTab) => { + if (tab === activeTab) return; + setActiveTab(tab); + setPage(1); + setSelectedInterest(undefined); + setPosts([]); + setIsLoading(true); + }, + [activeTab], + ); + + const handleInterestPress = useCallback( + (name: string) => { + setSelectedInterest((prev) => (prev === name ? undefined : name)); + setPage(1); + setPosts([]); + setIsLoading(true); + }, + [], + ); + + const handleBookmark = useCallback( + async (postId: string) => { + try { + await bookmarkPost(postId); + setPosts((prev) => + prev.map((p) => + p.post_id === postId ? { ...p, bookmarked: !p.bookmarked } : p, + ), + ); + } catch (err) { + console.error('[Feeds] Bookmark error:', err); + } + }, + [], + ); + + const handleMarkSeen = useCallback( + async (postId: string) => { + try { + await markPostSeen(postId); + setPosts((prev) => + prev.map((p) => + p.post_id === postId ? { ...p, seen: true } : p, + ), + ); + } catch (err) { + console.error('[Feeds] Mark seen error:', err); + } + }, + [], + ); + + const handleOpenUrl = useCallback((url: string) => { + Linking.openURL(url); + }, []); + + const handleConnectMastodon = useCallback(async () => { + const rawUrl = instanceUrlInput.trim(); + if (!rawUrl) return; + try { + setIsConnecting(true); + setError(null); + + const redirectUri = AuthSession.makeRedirectUri({ + scheme: 'ushadow', + path: 'feed/mastodon/callback', + useProxy: false, + }); + + // 1. Ask backend to register the app and return the authorization URL + const authUrl = await getMastodonAuthUrl(rawUrl, redirectUri); + + // 2. Open browser — resolves when Mastodon redirects back to the app + const result = await WebBrowser.openAuthSessionAsync(authUrl, redirectUri); + if (result.type !== 'success') { + // User cancelled — not an error + return; + } + + // 3. Extract authorization code from the redirect URL + const code = new URL(result.url).searchParams.get('code'); + if (!code) { + setError('No authorization code received from Mastodon.'); + return; + } + + // 4. Exchange code for access token (backend stores it on the source) + await connectMastodon({ + instance_url: rawUrl, + code, + redirect_uri: redirectUri, + name: `Mastodon (${rawUrl})`, + }); + + // 5. Reload — source now has token, feed refresh will pull home timeline + setIsLoading(true); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Mastodon connection failed'); + } finally { + setIsConnecting(false); + } + }, [instanceUrlInput, loadData]); + + // ─── Helpers ───────────────────────────────────────────────────────── + + const timeAgo = (dateStr: string): string => { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + return `${days}d`; + }; + + const stripHtml = (html: string): string => + html + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); + + const formatCount = (n: number | null | undefined): string => { + if (n == null) return '—'; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); + }; + + // ─── Renderers ─────────────────────────────────────────────────────── + + const renderMastodonPost = (post: FeedPost) => ( + + {/* Author row */} + + {post.author_avatar ? ( + + ) : ( + + + {(post.author_display_name || post.author_handle).charAt(0).toUpperCase()} + + + )} + + + {post.author_display_name || post.author_handle} + + + {post.author_handle} · {timeAgo(post.published_at)} + + + {post.relevance_score > 0 && ( + + {post.relevance_score.toFixed(1)} + + )} + + + {/* Content */} + + {stripHtml(post.content)} + + + {/* Matched interests */} + {post.matched_interests.length > 0 && ( + + {post.matched_interests.slice(0, 3).map((name) => ( + + {name} + + ))} + + )} + + {/* Actions */} + + handleBookmark(post.post_id)} + style={[styles.actionBtn, post.bookmarked && styles.actionBtnActive]} + testID={`post-bookmark-${post.post_id}`} + > + + + {!post.seen && ( + handleMarkSeen(post.post_id)} + style={styles.actionBtn} + testID={`post-mark-seen-${post.post_id}`} + > + + + )} + handleOpenUrl(post.url)} + style={styles.actionBtn} + testID={`post-open-${post.post_id}`} + > + + Original + + + + ); + + const renderYoutubePost = (post: FeedPost) => { + const titleMatch = post.content.match(/(.*?)<\/b>/); + const title = titleMatch ? titleMatch[1] : stripHtml(post.content).slice(0, 100); + + return ( + handleOpenUrl(post.url)} + activeOpacity={0.8} + testID={`youtube-card-${post.post_id}`} + > + {/* Thumbnail */} + {post.thumbnail_url ? ( + + + {post.duration && ( + + {post.duration} + + )} + + ) : ( + + + + )} + + {/* Video info */} + + + {title} + + + {post.channel_title && ( + + {post.channel_title} + + )} + + {timeAgo(post.published_at)} + {post.view_count != null && ` · ${formatCount(post.view_count)} views`} + {post.like_count != null && ` · ${formatCount(post.like_count)} likes`} + + + + {/* Matched interests */} + {post.matched_interests.length > 0 && ( + + {post.matched_interests.slice(0, 3).map((name) => ( + + {name} + + ))} + + )} + + {/* Score + bookmark */} + + {post.relevance_score > 0 && ( + + {post.relevance_score.toFixed(1)} + + )} + { + e.stopPropagation?.(); + handleBookmark(post.post_id); + }} + style={[styles.actionBtn, post.bookmarked && styles.actionBtnActive]} + testID={`youtube-bookmark-${post.post_id}`} + > + + + + + + ); + }; + + const renderPost = ({ item }: { item: FeedPost }) => + item.platform_type === 'youtube' ? renderYoutubePost(item) : renderMastodonPost(item); + + const hasMastodonSource = sources.some((s) => s.platform_type === 'mastodon' && s.enabled); + + const renderEmptyState = () => ( + + {!isLoggedIn ? ( + <> + + Not Logged In + + Log in from the Home tab to view your feed + + + ) : activeTab === 'social' && !hasMastodonSource ? ( + /* ── Connect Mastodon ── */ + <> + + Connect Mastodon + + Sign in with your Mastodon account to see your home timeline, ranked by your interests. + + + + Instance + + + {isConnecting ? ( + + ) : ( + <> + + Connect with Mastodon + + )} + + + + ) : ( + <> + + No Posts Yet + + {activeTab === 'videos' + ? 'Add a YouTube source and refresh to see videos ranked by your interests' + : 'Refresh to fetch posts from your Mastodon home timeline'} + + + {isRefreshingFeed ? ( + + ) : ( + <> + + Refresh Feed + + )} + + + )} + + ); + + // ─── Main Render ───────────────────────────────────────────────────── + + return ( + + + + {/* Header */} + + + + Feed + + {total > 0 ? `${total} posts` : 'Ranked by your interests'} + + + + {isRefreshingFeed ? ( + + ) : ( + + )} + + + + {/* Sub-tabs: Social | Videos */} + + handleTabChange('social')} + testID="feed-tab-social" + > + + + Social + + + handleTabChange('videos')} + testID="feed-tab-videos" + > + + + Videos + + + + + + {/* Interest filter chips */} + {isLoggedIn && interests.length > 0 && ( + + handleInterestPress('')} + testID="feed-interest-all" + > + All + + {interests.slice(0, 15).map((interest) => ( + handleInterestPress(interest.name)} + testID={`feed-interest-${interest.node_id}`} + > + + {interest.name} + + + ))} + + )} + + {/* Error */} + {error && ( + + + {error} + + )} + + {/* Content */} + {isLoading ? ( + + + Loading feed... + + ) : ( + item.post_id} + contentContainerStyle={styles.listContent} + ListEmptyComponent={renderEmptyState} + refreshControl={ + + } + testID="feed-post-list" + /> + )} + + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Styles +// ═══════════════════════════════════════════════════════════════════════════ + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: theme.background }, + + // Header + header: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg, + paddingBottom: spacing.sm, + }, + headerTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: spacing.md, + }, + headerTitle: { + fontSize: fontSize['2xl'], + fontWeight: 'bold', + color: theme.textPrimary, + }, + headerSubtitle: { + fontSize: fontSize.sm, + color: theme.textSecondary, + marginTop: spacing.xs, + }, + headerRefreshBtn: { + width: 40, + height: 40, + borderRadius: borderRadius.md, + backgroundColor: colors.primary[400], + justifyContent: 'center', + alignItems: 'center', + }, + headerRefreshBtnDisabled: { opacity: 0.6 }, + + // Tabs + tabBar: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: theme.border, + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + gap: spacing.xs, + borderBottomWidth: 2, + borderBottomColor: 'transparent', + }, + tabActive: { borderBottomColor: colors.primary[400] }, + tabText: { + fontSize: fontSize.sm, + fontWeight: '500', + color: theme.textMuted, + }, + tabTextActive: { color: colors.primary[400] }, + + // Interest chips + chipWrap: { + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, + paddingBottom: spacing.xs, + gap: spacing.xs, + }, + chip: { + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderRadius: borderRadius.full, + backgroundColor: theme.backgroundCard, + }, + chipActive: { backgroundColor: colors.primary[400] }, + chipText: { + fontSize: fontSize.xs, + fontWeight: '500', + color: theme.textSecondary, + }, + chipTextActive: { color: theme.primaryButtonText }, + + // Error + errorBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.error.bg, + marginHorizontal: spacing.lg, + padding: spacing.md, + borderRadius: borderRadius.md, + marginBottom: spacing.sm, + }, + errorText: { + color: colors.error.default, + fontSize: fontSize.sm, + marginLeft: spacing.sm, + flex: 1, + }, + + // Loading + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + color: theme.textMuted, + fontSize: fontSize.sm, + marginTop: spacing.md, + }, + + // List + listContent: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing['3xl'], + }, + + // Post card (shared) + card: { + backgroundColor: theme.backgroundCard, + borderRadius: borderRadius.lg, + padding: spacing.lg, + marginBottom: spacing.md, + }, + cardSeen: { opacity: 0.65 }, + + // Mastodon author + authorRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.sm, + }, + avatar: { width: 40, height: 40, borderRadius: 20 }, + avatarPlaceholder: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: theme.backgroundInput, + justifyContent: 'center', + alignItems: 'center', + }, + avatarLetter: { + fontSize: fontSize.sm, + fontWeight: '600', + color: theme.textMuted, + }, + authorInfo: { flex: 1, marginLeft: spacing.sm }, + authorName: { + fontSize: fontSize.sm, + fontWeight: '600', + color: theme.textPrimary, + }, + authorHandle: { fontSize: fontSize.xs, color: theme.textMuted }, + + // Content + postContent: { + fontSize: fontSize.sm, + color: theme.textPrimary, + lineHeight: 22, + marginBottom: spacing.sm, + }, + + // Relevance score + scorePill: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.full, + backgroundColor: 'rgba(74, 222, 128, 0.15)', + }, + scoreText: { + fontSize: fontSize.xs, + fontWeight: '600', + color: colors.primary[400], + }, + + // Matched interests + interestRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginBottom: spacing.sm, + }, + matchedChip: { + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: borderRadius.sm, + backgroundColor: 'rgba(168, 85, 247, 0.15)', + }, + matchedChipText: { + fontSize: fontSize.xs, + color: colors.accent[400], + }, + + // Actions + actionsRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + marginTop: spacing.xs, + }, + actionBtn: { + flexDirection: 'row', + alignItems: 'center', + padding: spacing.xs, + borderRadius: borderRadius.sm, + gap: spacing.xs, + }, + actionBtnActive: { + backgroundColor: 'rgba(251, 191, 36, 0.1)', + }, + actionLabel: { + fontSize: fontSize.xs, + color: theme.textMuted, + }, + + // YouTube thumbnail + thumbnailContainer: { + width: '100%', + aspectRatio: 16 / 9, + borderRadius: borderRadius.md, + overflow: 'hidden', + backgroundColor: theme.backgroundInput, + marginBottom: spacing.sm, + }, + thumbnail: { width: '100%', height: '100%' }, + thumbnailPlaceholder: { + justifyContent: 'center', + alignItems: 'center', + }, + durationBadge: { + position: 'absolute', + bottom: spacing.xs, + right: spacing.xs, + backgroundColor: 'rgba(0,0,0,0.8)', + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: borderRadius.sm, + }, + durationText: { + fontSize: fontSize.xs, + fontWeight: '600', + color: colors.white, + }, + + // YouTube info + videoInfo: { gap: spacing.xs }, + videoTitle: { + fontSize: fontSize.sm, + fontWeight: '600', + color: theme.textPrimary, + lineHeight: 20, + }, + videoMeta: { gap: 2 }, + channelName: { + fontSize: fontSize.xs, + fontWeight: '500', + color: theme.textSecondary, + }, + videoMetaText: { + fontSize: fontSize.xs, + color: theme.textMuted, + }, + + // Empty state + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: spacing['3xl'] * 2, + }, + emptyTitle: { + fontSize: fontSize.lg, + fontWeight: '600', + color: theme.textPrimary, + marginTop: spacing.lg, + }, + emptySubtitle: { + fontSize: fontSize.sm, + color: theme.textMuted, + marginTop: spacing.sm, + textAlign: 'center', + paddingHorizontal: spacing.xl, + }, + refreshButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.primary[400], + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + marginTop: spacing.lg, + gap: spacing.sm, + }, + refreshButtonText: { + fontSize: fontSize.sm, + fontWeight: '600', + color: theme.primaryButtonText, + }, + + // Mastodon connect form + connectForm: { + width: '100%', + marginTop: spacing.lg, + gap: spacing.sm, + }, + connectLabel: { + fontSize: fontSize.xs, + fontWeight: '600', + color: theme.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.8, + }, + connectInput: { + backgroundColor: theme.backgroundInput, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: fontSize.sm, + color: theme.textPrimary, + borderWidth: 1, + borderColor: theme.border, + }, + connectButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primary[400], + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + 2, + borderRadius: borderRadius.md, + marginTop: spacing.xs, + gap: spacing.sm, + }, + connectButtonDisabled: { opacity: 0.6 }, + connectButtonText: { + fontSize: fontSize.sm, + fontWeight: '600', + color: theme.primaryButtonText, + }, +}); diff --git a/ushadow/mobile/app/(tabs)/index.tsx b/ushadow/mobile/app/(tabs)/index.tsx index 89e3b43b..ce5f238d 100644 --- a/ushadow/mobile/app/(tabs)/index.tsx +++ b/ushadow/mobile/app/(tabs)/index.tsx @@ -51,6 +51,7 @@ export default function HomeScreen() { // UI state const [showLogViewer, setShowLogViewer] = useState(false); + const [autoStartKeycloak, setAutoStartKeycloak] = useState(false); const [connectionState, setConnectionState] = useState( createInitialConnectionState() ); @@ -86,9 +87,11 @@ export default function HomeScreen() { const hostname = activeUnode.hostname || activeUnode.name; setCurrentHostname(hostname); - setCurrentApiUrl(apiUrl || activeUnode.apiUrl); + // Always prefer the active unode's URL — getApiUrl() returns the last + // successful login URL which may point to a different (old) server. + setCurrentApiUrl(activeUnode.apiUrl); console.log('[Home] Loaded active unode hostname:', hostname); - console.log('[Home] Loaded API URL:', apiUrl || activeUnode.apiUrl); + console.log('[Home] Loaded API URL:', activeUnode.apiUrl); } else if (apiUrl) { setCurrentApiUrl(apiUrl); console.log('[Home] Loaded API URL (no active unode):', apiUrl); @@ -139,6 +142,7 @@ export default function HomeScreen() { // Populate connection info from the freshly scanned unode setCurrentApiUrl(activeUnode.apiUrl); setCurrentHostname(activeUnode.hostname || activeUnode.name); + setAutoStartKeycloak(true); setShowLoginScreen(true); } } @@ -156,6 +160,7 @@ export default function HomeScreen() { const info = await getAuthInfo(); setAuthInfo(info); setShowLoginScreen(false); + setAutoStartKeycloak(false); setConnectionState((prev) => ({ ...prev, server: 'connected' })); logEvent('server', 'connected', 'Login successful', info?.email); }, @@ -272,10 +277,11 @@ export default function HomeScreen() { {/* Login Screen Modal */} setShowLoginScreen(false)} + onClose={() => { setShowLoginScreen(false); setAutoStartKeycloak(false); }} onLoginSuccess={handleLoginSuccess} initialApiUrl={currentApiUrl} hostname={currentHostname} + autoStartKeycloak={autoStartKeycloak} /> {/* Connection Log Viewer Modal */} diff --git a/ushadow/mobile/app/components/LoginScreenWithKeycloak.tsx b/ushadow/mobile/app/components/LoginScreenWithKeycloak.tsx index a9a74fcb..15ec4b9b 100644 --- a/ushadow/mobile/app/components/LoginScreenWithKeycloak.tsx +++ b/ushadow/mobile/app/components/LoginScreenWithKeycloak.tsx @@ -5,7 +5,7 @@ * The component auto-detects if Keycloak is available on the backend. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { View, Text, @@ -32,6 +32,7 @@ interface LoginScreenProps { onLoginSuccess: (token: string, apiUrl: string) => void; initialApiUrl?: string; hostname?: string; // UNode hostname for fetching Keycloak config + autoStartKeycloak?: boolean; // Auto-trigger OAuth when Keycloak is detected (e.g. QR scan) } export const LoginScreen: React.FC = ({ @@ -40,6 +41,7 @@ export const LoginScreen: React.FC = ({ onLoginSuccess, initialApiUrl = '', hostname, + autoStartKeycloak = false, }) => { const [apiUrl, setApiUrl] = useState(initialApiUrl || ''); const [loading, setLoading] = useState(false); @@ -50,6 +52,9 @@ export const LoginScreen: React.FC = ({ const [checkingKeycloak, setCheckingKeycloak] = useState(false); const [keycloakEnabled, setKeycloakEnabled] = useState(null); + // Guard: only auto-start once per modal open + const autoStartedRef = useRef(false); + // Debug logging console.log('[LoginScreen] Props:', { visible, initialApiUrl, hostname }); @@ -76,6 +81,27 @@ export const LoginScreen: React.FC = ({ } }, [apiUrl, hostname]); + // Reset auto-start guard when modal closes + useEffect(() => { + if (!visible) { + autoStartedRef.current = false; + } + }, [visible]); + + // Auto-start Keycloak OAuth when triggered by QR scan + useEffect(() => { + if ( + autoStartKeycloak && + keycloakEnabled === true && + !checkingKeycloak && + !loading && + !autoStartedRef.current + ) { + autoStartedRef.current = true; + handleKeycloakLogin(); + } + }, [autoStartKeycloak, keycloakEnabled, checkingKeycloak, loading]); + const checkKeycloakAvailability = async () => { const url = extractBaseUrl(apiUrl); if (!url) return; @@ -195,37 +221,43 @@ export const LoginScreen: React.FC = ({ {/* Form */} - Sign in to Ushadow + + {hostname ? `Sign in to ${hostname}` : 'Sign in to Ushadow'} + - Enter your server URL to connect to your leader node + {hostname + ? extractBaseUrl(initialApiUrl || apiUrl) || 'Checking server…' + : 'Enter your server URL to connect to your leader node'} - {/* API URL */} - - Server URL - - {/* Save as default checkbox */} - setSaveAsDefault(!saveAsDefault)} - testID="login-save-default" - > - - {saveAsDefault && } - - Save as default server - - + {/* API URL — only show editable field when not QR-triggered */} + {!hostname && ( + + Server URL + + {/* Save as default checkbox */} + setSaveAsDefault(!saveAsDefault)} + testID="login-save-default" + > + + {saveAsDefault && } + + Save as default server + + + )} {/* Loading indicator while checking Keycloak */} {checkingKeycloak && ( diff --git a/ushadow/mobile/app/services/feedApi.ts b/ushadow/mobile/app/services/feedApi.ts new file mode 100644 index 00000000..091250e6 --- /dev/null +++ b/ushadow/mobile/app/services/feedApi.ts @@ -0,0 +1,212 @@ +/** + * Feed API Client — Mobile + * + * Authenticated HTTP calls for the personalized multi-platform feed. + * Routes directly to ushadow backend at /api/feed/*. + */ + +import { getAuthToken, getApiUrl } from '../_utils/authStorage'; +import { getActiveUnode } from '../_utils/unodeStorage'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Types (mirrors backend models + web feedApi.ts) +// ═══════════════════════════════════════════════════════════════════════════ + +export interface FeedPost { + post_id: string; + user_id: string; + source_id: string; + external_id: string; + platform_type: 'mastodon' | 'youtube'; + author_handle: string; + author_display_name: string; + author_avatar: string | null; + content: string; + url: string; + published_at: string; + hashtags: string[]; + language: string | null; + // Mastodon + boosts_count: number | null; + favourites_count: number | null; + replies_count: number | null; + // YouTube + thumbnail_url?: string | null; + video_id?: string | null; + channel_title?: string | null; + view_count?: number | null; + like_count?: number | null; + duration?: string | null; + // Scoring & interaction + relevance_score: number; + matched_interests: string[]; + seen: boolean; + bookmarked: boolean; + fetched_at: string; +} + +export interface FeedInterest { + name: string; + node_id: string; + labels: string[]; + relationship_count: number; + last_active: string | null; + hashtags: string[]; +} + +export interface FeedResponse { + posts: FeedPost[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface RefreshResult { + status: string; + interests_count: number; + posts_fetched: number; + posts_new: number; + message?: string; +} + +export interface FeedStats { + total_posts: number; + unseen_posts: number; + bookmarked_posts: number; + sources_count: number; +} + +export interface PostSource { + source_id: string; + name: string; + platform_type: 'mastodon' | 'youtube'; + instance_url: string | null; + enabled: boolean; + // access_token intentionally omitted — sensitive, not needed by mobile +} + +export interface MastodonConnectRequest { + instance_url: string; + code: string; + redirect_uri: string; + name?: string; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Helpers (same pattern as memoriesApi.ts) +// ═══════════════════════════════════════════════════════════════════════════ + +async function getBaseUrl(): Promise { + const activeUnode = await getActiveUnode(); + if (activeUnode?.apiUrl) return activeUnode.apiUrl; + + const storedUrl = await getApiUrl(); + if (storedUrl) return storedUrl; + + return 'https://ushadow.wolf-tawny.ts.net'; +} + +async function getToken(): Promise { + const activeUnode = await getActiveUnode(); + if (activeUnode?.authToken) return activeUnode.authToken; + return getAuthToken(); +} + +async function feedRequest( + endpoint: string, + options: RequestInit = {}, +): Promise { + const [baseUrl, token] = await Promise.all([getBaseUrl(), getToken()]); + + if (!token) { + throw new Error('Not authenticated. Please log in first.'); + } + + const url = `${baseUrl}/api/feed${endpoint}`; + console.log(`[FeedAPI] ${options.method || 'GET'} ${url}`); + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[FeedAPI] Request failed: ${response.status}`, errorText); + throw new Error(`Feed API request failed: ${response.status}`); + } + + return response.json(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Public API +// ═══════════════════════════════════════════════════════════════════════════ + +export async function fetchFeedPosts(params: { + page?: number; + page_size?: number; + interest?: string; + show_seen?: boolean; + platform_type?: string; +}): Promise { + const query = new URLSearchParams(); + if (params.page) query.set('page', String(params.page)); + if (params.page_size) query.set('page_size', String(params.page_size)); + if (params.interest) query.set('interest', params.interest); + if (params.show_seen !== undefined) query.set('show_seen', String(params.show_seen)); + if (params.platform_type) query.set('platform_type', params.platform_type); + + const qs = query.toString(); + return feedRequest(`/posts${qs ? `?${qs}` : ''}`); +} + +export async function fetchFeedInterests(): Promise { + const data = await feedRequest<{ interests: FeedInterest[] }>('/interests'); + return data.interests; +} + +export async function refreshFeed(platformType?: string): Promise { + const qs = platformType ? `?platform_type=${platformType}` : ''; + return feedRequest(`/refresh${qs}`, { method: 'POST' }); +} + +export async function markPostSeen(postId: string): Promise { + await feedRequest(`/posts/${postId}/seen`, { method: 'POST' }); +} + +export async function bookmarkPost(postId: string): Promise { + await feedRequest(`/posts/${postId}/bookmark`, { method: 'POST' }); +} + +export async function fetchFeedStats(): Promise { + return feedRequest('/stats'); +} + +export async function fetchFeedSources(): Promise { + const data = await feedRequest<{ sources: PostSource[] }>('/sources'); + return data.sources; +} + +export async function getMastodonAuthUrl( + instanceUrl: string, + redirectUri: string, +): Promise { + const qs = new URLSearchParams({ instance_url: instanceUrl, redirect_uri: redirectUri }); + const data = await feedRequest<{ authorization_url: string }>( + `/sources/mastodon/auth-url?${qs}`, + ); + return data.authorization_url; +} + +export async function connectMastodon(req: MastodonConnectRequest): Promise { + return feedRequest('/sources/mastodon/connect', { + method: 'POST', + body: JSON.stringify(req), + }); +} diff --git a/ushadow/mobile/app/unode-details.tsx b/ushadow/mobile/app/unode-details.tsx index db15ee84..51805414 100644 --- a/ushadow/mobile/app/unode-details.tsx +++ b/ushadow/mobile/app/unode-details.tsx @@ -423,7 +423,7 @@ export default function UNodeDetailsPage() { // Ensure apiUrl is always the base URL (remove any /api/... path) const baseApiUrl = result.leader.apiUrl.replace(/\/api\/.*$/, ''); - await saveUnode({ + const savedUnode = await saveUnode({ id: rescanNodeId!, // Keep same ID name: existingNode?.name || result.leader.hostname.split('.')[0] || 'UNode', hostname: result.leader.hostname, // Save actual hostname (e.g., "Orion") @@ -435,8 +435,9 @@ export default function UNodeDetailsPage() { authToken: undefined, }); - // Reload unodes - await getUnodes(); + // Set as active unode so home screen reads the correct server on next focus + await setActiveUnode(savedUnode.id); + setRescanNodeId(null); // Clear any existing auth token to force Keycloak login diff --git a/ushadow/mobile/package-lock.json b/ushadow/mobile/package-lock.json index 8d678b62..26d81c0f 100644 --- a/ushadow/mobile/package-lock.json +++ b/ushadow/mobile/package-lock.json @@ -99,6 +99,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1465,6 +1466,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -3272,6 +3274,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.26.tgz", "integrity": "sha512-RhKmeD0E2ejzKS6z8elAfdfwShpcdkYY8zJzvHYLq+wv183BBcElTeyMLcIX6wIn7QutXeI92Yi21t7aUWfqNQ==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.13.7", "escape-string-regexp": "^4.0.0", @@ -3470,6 +3473,7 @@ "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3540,6 +3544,7 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -4108,6 +4113,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4808,6 +4814,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5791,6 +5798,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5987,6 +5995,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6225,6 +6234,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.23", @@ -6415,6 +6425,7 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", "license": "MIT", + "peer": true, "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" @@ -6524,6 +6535,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -6591,6 +6603,7 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", "license": "MIT", + "peer": true, "dependencies": { "expo-constants": "~18.0.12", "invariant": "^2.2.4" @@ -10816,6 +10829,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10852,6 +10866,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", @@ -10934,6 +10949,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -10959,6 +10975,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -10969,6 +10986,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -10984,6 +11002,7 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -11090,6 +11109,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12401,6 +12421,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12617,6 +12638,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"