[WIP] mobile app - #182
Closed
kawamataryo wants to merge 50 commits into
Closed
Conversation
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..7b4025c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,197 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Sky Follower Bridge is a browser extension that helps users migrate their social connections from X (Twitter), Threads, Instagram, TikTok, and Facebook to Bluesky. It uses web scraping to detect users on these platforms and matches them with Bluesky accounts using fuzzy search algorithms.
+
+**Tech Stack:**
+- **Framework**: Plasmo (Manifest V3 browser extension framework)
+- **Frontend**: React 18.2.0 + TypeScript
+- **Styling**: Tailwind CSS + DaisyUI
+- **Bluesky API**: @atproto/api
+- **Testing**: Vitest + Happy DOM
+- **Linting**: Biome
+- **Backend**: Cloudflare Workers (Hono framework)
+
+## Development Commands
+
+```bash
+# Development
+npm run dev # Start development server (Chrome)
+npm run dev:firefox # Start development server (Firefox)
+
+# Building
+npm run build # Build for Chrome (creates build/chrome-mv3-prod)
+npm run build:firefox # Build for Firefox
+npm run package # Package for Chrome distribution
+npm run package:firefox # Package for Firefox distribution
+
+# Testing & Quality
+npm run test # Run Vitest tests
+npm run check # Run Biome formatter/linter (auto-fix)
+npm run check:ci # CI check (no auto-fix)
+
+# Component Development
+npm run storybook # Launch Storybook on port 6006
+npm run build-storybook # Build Storybook
+
+# Documentation
+npm run docs:dev # Start VitePress dev server
+npm run docs:build # Build VitePress docs
+npm run docs:preview # Preview built docs
+```
+
+## Architecture
+
+### High-Level Flow
+
+```
+User opens Extension (Alt+B)
+ ↓
+[Popup] Login to Bluesky → Start Search
+ ↓
+[Content Script] Detects current page → Scrapes users → Matches with Bluesky
+ ↓
+[Service Worker] Performs Bluesky API operations (follow/block/list)
+ ↓
+[Popup] Shows matched users in modal
+```
+
+### Core Components
+
+1. **Popup** (`src/popup.tsx`): Main UI for authentication and search initiation
+2. **Content Scripts** (`src/contents/`): Injected into target sites to scrape user data
+3. **Service Worker** (`src/background/messages/`): Handles Bluesky API communication
+4. **Services** (`src/services/`): Platform-specific scraping logic for X, Threads, Instagram, TikTok, Facebook
+
+### Message Handlers (`src/background/messages/`)
+
+All Bluesky API operations are handled via Plasmo message handlers:
+- `login.ts`: Authenticate with Bluesky
+- `follow.ts`, `unfollow.ts`: Follow/unfollow operations
+- `block.ts`, `unblock.ts`: Block/unblock operations
+- `searchUser.ts`: Search for users on Bluesky
+- `createList.ts`, `addUserToList.ts`: List management
+- `getMyProfile.ts`: Fetch authenticated user profile
+- `getImageSimilarityScore.ts`: Calculate avatar similarity
+
+### Platform Services (`src/services/`)
+
+Each service implements the same interface for scraping user data:
+- `xService.ts`: X.com (Twitter) following/followers/blocked pages
+- `threadsService.ts`: Threads user lists
+- `instagramService.ts`: Instagram followers/following
+- `tikTokService.ts`: TikTok user lists
+- `facebookService.ts`: Facebook friends
+
+**Service Pattern:**
+```typescript
+class XService {
+ extractUsersFromDom(): CrawledUserInfo[]
+ observeDomChanges(callback: (users: CrawledUserInfo[]) => void): void
+}
+```
+
+### User Matching Algorithm
+
+Location: `src/lib/fuzzySearchBskyUser.ts`
+
+Uses Jaro-Winkler algorithm to match scraped users with Bluesky accounts:
+1. **Handle matching**: Exact or fuzzy match on username
+2. **Display name matching**: Fuzzy match on display name
+3. **Description matching**: Check if X/Threads handle appears in Bluesky bio
+4. **Avatar similarity**: Calculate image similarity score (threshold: 0.6)
+
+Match types are defined in `BSKY_USER_MATCH_TYPE` (handle, display_name, description, none).
+
+### Constants (`src/lib/constants.ts`)
+
+Critical configuration file containing:
+- `TARGET_URLS_REGEX`: URL patterns that trigger the extension
+- `MESSAGE_NAMES`: Inter-component communication message types
+- `ACTION_MODE`: Follow, block, or import_list operations
+- `STORAGE_KEYS`: Browser storage key prefixes
+- `FILTER_TYPE`: User filtering criteria in results modal
+- `BSKY_DOMAIN`: Configurable via `PLASMO_PUBLIC_BSKY_DOMAIN` env var
+
+## Key Files to Understand
+
+When working with this codebase, start with these files in order:
+
+1. **`src/lib/constants.ts`**: All enums, regex patterns, and configuration
+2. **`src/popup.tsx`**: Main user interface entry point
+3. **`src/hooks/useSearch.ts`**: Search initialization logic
+4. **`src/contents/App.tsx`**: Content script that coordinates scraping
+5. **`src/lib/fuzzySearchBskyUser.ts`**: Core matching algorithm
+6. **`src/background/messages/`**: Bluesky API operations
+
+## Storage Architecture
+
+Uses `@plasmohq/storage` for persistent data:
+- `BSKY_CLIENT_SESSION`: Authenticated Bluesky session
+- `DETECTED_BSKY_USERS`: Matched Bluesky users for current search
+- `BSKY_MESSAGE_NAME`: Current operation mode (follow/block/list)
+- `LIST_NAME`: Name for imported lists
+
+## Multi-Platform Support
+
+The extension detects the current page using `TARGET_URLS_REGEX` and instantiates the appropriate service:
+- X.com: Follow, Followers, Blocked users, List members
+- Threads: All pages
+- Instagram: Followers/Following pages
+- TikTok: User pages
+- Facebook: Friends list
+
+## Custom PDS Support
+
+The extension can be built for custom Bluesky PDS servers:
+```bash
+PLASMO_PUBLIC_BSKY_DOMAIN=custom-domain.com npm run build
+```
+
+Default domain is `bsky.social` (defined in `src/lib/constants.ts`).
+
+## Browser Extension Loading
+
+**Chrome/Edge:**
+1. Navigate to `chrome://extensions/`
+2. Enable "Developer mode"
+3. Click "Load unpacked"
+4. Select `build/chrome-mv3-prod`
+
+**Firefox:**
+1. Navigate to `about:debugging#/runtime/this-firefox`
+2. Click "Load Temporary Add-on"
+3. Select the `.zip` file from `build/`
+
+## Testing
+
+- Tests are located alongside source files (e.g., `src/lib/__tests__/`)
+- Run with `npm run test`
+- Uses Vitest with Happy DOM for browser environment simulation
+
+## Common Patterns
+
+### Adding a New Platform Service
+
+1. Create service file in `src/services/` (e.g., `newPlatformService.ts`)
+2. Implement `extractUsersFromDom()` and `observeDomChanges()` methods
+3. Add URL pattern to `TARGET_URLS_REGEX` in `src/lib/constants.ts`
+4. Add message name to `MESSAGE_NAMES`
+5. Update `src/contents/App.tsx` to instantiate the new service
+6. Add host permission to `manifest.host_permissions` in `package.json`
+
+### Adding a New Bluesky Operation
+
+1. Create message handler in `src/background/messages/` (e.g., `newOperation.ts`)
+2. Use `getBskyServiceWorkerClient()` from `src/lib/bskyServiceWorkerClient.ts`
+3. Export handler using Plasmo's message handler pattern
+4. Call from frontend using `@plasmohq/messaging`
+
+## Internationalization
+
+- Locale files in `locales/` directory (JSON format)
+- Access translations via `chrome.i18n.getMessage(key)`
+- Default locale: `en` (set in `package.json` manifest)
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index 10b79ed..fe2252d 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -13,6 +13,7 @@ export default function RootLayout() {
<Stack.Screen name="x-login" />
<Stack.Screen name="scan" />
<Stack.Screen name="results" />
+ <Stack.Screen name="profile" />
</Stack>
</ScanProvider>
</AuthProvider>
diff --git a/mobile/app/auth.tsx b/mobile/app/auth.tsx
index 22db89b..1479e1a 100644
--- a/mobile/app/auth.tsx
+++ b/mobile/app/auth.tsx
@@ -1,3 +1,4 @@
+import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { useState } from "react";
@@ -12,17 +13,40 @@ import {
TouchableOpacity,
View,
} from "react-native";
+import { TypeaheadDropdown } from "~/components/TypeaheadDropdown";
import { useAuth } from "~/contexts/AuthContext";
import { colors, radius, shadows, spacing, typography } from "~/lib/theme";
export default function AuthScreen() {
const router = useRouter();
- const { loginWithAppPassword } = useAuth();
+ const { loginWithAppPassword, loginWithOAuth } = useAuth();
+
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
+ const [showAppPassword, setShowAppPassword] = useState(false);
+ const [showTypeahead, setShowTypeahead] = useState(false);
+
+ const handleOAuthLogin = async (handleOverride?: string) => {
+ const handle = handleOverride || identifier.trim();
+ if (!handle) {
+ Alert.alert("Error", "Please enter your Bluesky handle.");
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ await loginWithOAuth(handle);
+ router.replace("/x-login-guide");
+ } catch (e) {
+ const message = e instanceof Error ? e.message : "OAuth login failed";
+ Alert.alert("Login Error", message);
+ } finally {
+ setIsLoading(false);
+ }
+ };
- const handleLogin = async () => {
+ const handleAppPasswordLogin = async () => {
if (!identifier.trim() || !password.trim()) {
Alert.alert("Error", "Please enter your identifier and app password.");
return;
@@ -64,10 +88,10 @@ export default function AuthScreen() {
<Text style={styles.title}>Connect Bluesky</Text>
<Text style={styles.subtitle}>
- Use your handle and an App Password
+ Sign in to your Bluesky account to get started
</Text>
- {/* Handle Input */}
+ {/* Handle Input (shared by both methods) */}
<View style={styles.inputContainer}>
<Text style={styles.inputPrefix}>@</Text>
<TextInput
@@ -79,48 +103,122 @@ export default function AuthScreen() {
autoCapitalize="none"
autoCorrect={false}
keyboardAppearance="dark"
- returnKeyType="next"
+ returnKeyType={showAppPassword ? "next" : "done"}
+ onSubmitEditing={showAppPassword ? undefined : () => handleOAuthLogin()}
+ onFocus={() => !showAppPassword && setShowTypeahead(true)}
+ onBlur={() => setTimeout(() => setShowTypeahead(false), 200)}
/>
</View>
- {/* Password Input */}
- <View style={styles.inputContainer}>
- <TextInput
- style={[styles.input, styles.inputFull]}
- placeholder="App Password"
- placeholderTextColor={colors.text.tertiary}
- value={password}
- onChangeText={setPassword}
- secureTextEntry
- autoCapitalize="none"
- autoCorrect={false}
- keyboardAppearance="dark"
- returnKeyType="done"
- onSubmitEditing={handleLogin}
+ {/* Typeahead dropdown (OAuth mode only) */}
+ {!showAppPassword && (
+ <TypeaheadDropdown
+ query={identifier}
+ visible={showTypeahead && !isLoading}
+ onSelect={(handle) => {
+ setIdentifier(handle);
+ setShowTypeahead(false);
+ handleOAuthLogin(handle);
+ }}
/>
- </View>
- <Text style={styles.hint}>
- Generate an App Password in Bluesky Settings → Privacy & Security
- </Text>
+ )}
+
+ {/* OAuth Button (primary) */}
+ {!showAppPassword && (
+ <>
+ <TouchableOpacity
+ style={[styles.oauthButton, isLoading && styles.buttonDisabled]}
+ onPress={() => handleOAuthLogin()}
+ disabled={isLoading}
+ activeOpacity={0.85}
+ >
+ <LinearGradient
+ colors={[...colors.gradient.button]}
+ start={{ x: 0, y: 0 }}
+ end={{ x: 1, y: 0 }}
+ style={styles.oauthButtonGradient}
+ >
+ <Ionicons
+ name="log-in-outline"
+ size={20}
+ color={colors.text.primary}
+ style={styles.oauthIcon}
+ />
+ <Text style={styles.oauthButtonText}>
+ {isLoading ? "Signing in..." : "Sign in with Bluesky"}
+ </Text>
+ </LinearGradient>
+ </TouchableOpacity>
+
+ <Text style={styles.oauthHint}>
+ You'll be redirected to Bluesky to authorize this app
+ </Text>
+
+ {/* App Password fallback link */}
+ <TouchableOpacity
+ style={styles.fallbackLink}
+ onPress={() => setShowAppPassword(true)}
+ activeOpacity={0.7}
+ >
+ <Text style={styles.fallbackText}>
+ Use App Password instead
+ </Text>
+ </TouchableOpacity>
+ </>
+ )}
- {/* Sign In Button */}
- <TouchableOpacity
- style={[styles.button, isLoading && styles.buttonDisabled]}
- onPress={handleLogin}
- disabled={isLoading}
- activeOpacity={0.85}
- >
- <LinearGradient
- colors={[...colors.gradient.button]}
- start={{ x: 0, y: 0 }}
- end={{ x: 1, y: 0 }}
- style={styles.buttonGradient}
- >
- <Text style={styles.buttonText}>
- {isLoading ? "Signing in..." : "Sign In"}
+ {/* App Password section (fallback) */}
+ {showAppPassword && (
+ <>
+ <View style={styles.inputContainer}>
+ <TextInput
+ style={[styles.input, styles.inputFull]}
+ placeholder="App Password"
+ placeholderTextColor={colors.text.tertiary}
+ value={password}
+ onChangeText={setPassword}
+ secureTextEntry
+ autoCapitalize="none"
+ autoCorrect={false}
+ keyboardAppearance="dark"
+ returnKeyType="done"
+ onSubmitEditing={handleAppPasswordLogin}
+ />
+ </View>
+ <Text style={styles.hint}>
+ Generate an App Password in Bluesky Settings → Privacy & Security
</Text>
- </LinearGradient>
- </TouchableOpacity>
+
+ <TouchableOpacity
+ style={[styles.oauthButton, isLoading && styles.buttonDisabled]}
+ onPress={handleAppPasswordLogin}
+ disabled={isLoading}
+ activeOpacity={0.85}
+ >
+ <LinearGradient
+ colors={[...colors.gradient.button]}
+ start={{ x: 0, y: 0 }}
+ end={{ x: 1, y: 0 }}
+ style={styles.oauthButtonGradient}
+ >
+ <Text style={styles.oauthButtonText}>
+ {isLoading ? "Signing in..." : "Sign In"}
+ </Text>
+ </LinearGradient>
+ </TouchableOpacity>
+
+ {/* Back to OAuth link */}
+ <TouchableOpacity
+ style={styles.fallbackLink}
+ onPress={() => setShowAppPassword(false)}
+ activeOpacity={0.7}
+ >
+ <Text style={styles.fallbackText}>
+ ← Back to OAuth sign in
+ </Text>
+ </TouchableOpacity>
+ </>
+ )}
</ScrollView>
</KeyboardAvoidingView>
</LinearGradient>
@@ -198,13 +296,7 @@ const styles = StyleSheet.create({
inputFull: {
paddingLeft: 0,
},
- hint: {
- fontSize: typography.sizes.caption,
- color: colors.text.tertiary,
- marginBottom: spacing.xl,
- marginTop: -spacing.xs,
- },
- button: {
+ oauthButton: {
borderRadius: radius.lg,
overflow: "hidden",
marginTop: spacing.sm,
@@ -213,15 +305,41 @@ const styles = StyleSheet.create({
buttonDisabled: {
opacity: 0.6,
},
- buttonGradient: {
+ oauthButtonGradient: {
+ flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 16,
paddingHorizontal: spacing.xl,
+ gap: spacing.sm,
},
- buttonText: {
+ oauthIcon: {
+ marginRight: spacing.xs,
+ },
+ oauthButtonText: {
fontSize: typography.sizes.body,
fontWeight: typography.weights.semibold,
color: colors.text.primary,
},
+ oauthHint: {
+ fontSize: typography.sizes.caption,
+ color: colors.text.tertiary,
+ textAlign: "center",
+ marginTop: spacing.md,
+ },
+ hint: {
+ fontSize: typography.sizes.caption,
+ color: colors.text.tertiary,
+ marginBottom: spacing.xl,
+ marginTop: -spacing.xs,
+ },
+ fallbackLink: {
+ alignItems: "center",
+ marginTop: spacing.xl,
+ paddingVertical: spacing.sm,
+ },
+ fallbackText: {
+ fontSize: typography.sizes.bodySmall,
+ color: colors.text.tertiary,
+ },
});
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index 06721ee..6d7e807 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -1,18 +1,55 @@
+import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
import {
ActivityIndicator,
+ Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useAuth } from "~/contexts/AuthContext";
+import { colors, radius, shadows, spacing, typography } from "~/lib/theme";
export default function WelcomeScreen() {
const router = useRouter();
const { isLoading, isLoggedIn } = useAuth();
+ const fadeIn = useRef(new Animated.Value(0)).current;
+ const slideUp = useRef(new Animated.Value(40)).current;
+ const logoScale = useRef(new Animated.Value(0.8)).current;
+ const glowOpacity = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.sequence([
+ Animated.parallel([
+ Animated.timing(logoScale, {
+ toValue: 1,
+ duration: 800,
+ useNativeDriver: true,
+ }),
+ Animated.timing(glowOpacity, {
+ toValue: 1,
+ duration: 1200,
+ useNativeDriver: true,
+ }),
+ ]),
+ Animated.parallel([
+ Animated.timing(fadeIn, {
+ toValue: 1,
+ duration: 600,
+ useNativeDriver: true,
+ }),
+ Animated.timing(slideUp, {
+ toValue: 0,
+ duration: 600,
+ useNativeDriver: true,
+ }),
+ ]),
+ ]).start();
+ }, [fadeIn, slideUp, logoScale, glowOpacity]);
+
useEffect(() => {
if (!isLoading && isLoggedIn) {
router.replace("/x-login-guide");
@@ -21,56 +58,179 @@ export default function WelcomeScreen() {
if (isLoading) {
return (
- <View style={styles.container}>
- <ActivityIndicator size="large" color="#0085FF" />
- </View>
+ <LinearGradient colors={[...colors.gradient.aurora]} style={styles.container}>
+ <ActivityIndicator size="large" color={colors.accent.cyan} />
+ </LinearGradient>
);
}
return (
- <View style={styles.container}>
- <Text style={styles.title}>Sky Follower Bridge</Text>
- <Text style={styles.subtitle}>
- Find your X follows on Bluesky
- </Text>
- <TouchableOpacity
- style={styles.button}
- onPress={() => router.push("/auth")}
- >
- <Text style={styles.buttonText}>Get Started</Text>
- </TouchableOpacity>
- </View>
+ <LinearGradient colors={[...colors.gradient.aurora]} style={styles.container}>
+ {/* Aurora glow effect */}
+ <Animated.View style={[styles.glowOrb, { opacity: glowOpacity }]}>
+ <LinearGradient
+ colors={["rgba(0, 133, 255, 0.15)", "rgba(0, 194, 255, 0.08)", "transparent"]}
+ style={styles.glowGradient}
+ start={{ x: 0.5, y: 0 }}
+ end={{ x: 0.5, y: 1 }}
+ />
+ </Animated.View>
+
+ <View style={styles.content}>
+ {/* Logo / Brand */}
+ <Animated.View style={[styles.logoContainer, { transform: [{ scale: logoScale }] }]}>
+ <View style={styles.iconCircle}>
+ <LinearGradient
+ colors={[...colors.gradient.accent]}
+ start={{ x: 0, y: 0 }}
+ end={{ x: 1, y: 1 }}
+ style={styles.iconGradient}
+ >
+ <Text style={styles.iconText}>SFB</Text>
+ </LinearGradient>
+ </View>
+ </Animated.View>
+
+ <Animated.View
+ style={[
+ styles.textContainer,
+ { opacity: fadeIn, transform: [{ translateY: slideUp }] },
+ ]}
+ >
+ <Text style={styles.title}>Sky Follower</Text>
+ <Text style={styles.titleAccent}>Bridge</Text>
+ <Text style={styles.subtitle}>
+ Find your X connections on Bluesky
+ </Text>
+ </Animated.View>
+
+ <Animated.View
+ style={[
+ styles.buttonContainer,
+ { opacity: fadeIn, transform: [{ translateY: slideUp }] },
+ ]}
+ >
+ <TouchableOpacity
+ style={styles.button}
+ onPress={() => router.push("/auth")}
+ activeOpacity={0.85}
+ >
+ <LinearGradient
+ colors={[...colors.gradient.button]}
+ start={{ x: 0, y: 0 }}
+ end={{ x: 1, y: 0 }}
+ style={styles.buttonGradient}
+ >
+ <Text style={styles.buttonText}>Get Started</Text>
+ <Text style={styles.buttonArrow}>→</Text>
+ </LinearGradient>
+ </TouchableOpacity>
+
+ <Text style={styles.versionText}>v1.0.0</Text>
+ </Animated.View>
+ </View>
+ </LinearGradient>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
+ },
+ content: {
+ flex: 1,
justifyContent: "center",
alignItems: "center",
- padding: 24,
- backgroundColor: "#fff",
+ padding: spacing.xl,
+ },
+ glowOrb: {
+ position: "absolute",
+ top: -100,
+ left: -50,
+ right: -50,
+ height: 400,
+ },
+ glowGradient: {
+ flex: 1,
+ borderRadius: 200,
+ },
+ logoContainer: {
+ marginBottom: spacing.xxl,
+ },
+ iconCircle: {
+ width: 88,
+ height: 88,
+ borderRadius: 44,
+ overflow: "hidden",
+ ...shadows.glow,
+ },
+ iconGradient: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ iconText: {
+ fontSize: typography.sizes.h2,
+ fontWeight: typography.weights.heavy,
+ color: colors.text.inverse,
+ letterSpacing: typography.letterSpacing.wide,
+ },
+ textContainer: {
+ alignItems: "center",
+ marginBottom: spacing.xxxl,
},
title: {
- fontSize: 28,
- fontWeight: "bold",
- marginBottom: 8,
+ fontSize: typography.sizes.hero,
+ fontWeight: typography.weights.bold,
+ color: colors.text.primary,
+ letterSpacing: typography.letterSpacing.tight,
+ },
+ titleAccent: {
+ fontSize: typography.sizes.hero,
+ fontWeight: typography.weights.heavy,
+ color: colors.accent.cyan,
+ letterSpacing: typography.letterSpacing.tight,
+ marginTop: -4,
},
subtitle: {
- fontSize: 16,
- color: "#666",
- marginBottom: 32,
+ fontSize: typography.sizes.body,
+ color: colors.text.secondary,
+ marginTop: spacing.md,
textAlign: "center",
},
+ buttonContainer: {
+ width: "100%",
+ alignItems: "center",
+ },
button: {
- backgroundColor: "#0085FF",
- paddingHorizontal: 32,
- paddingVertical: 14,
- borderRadius: 12,
+ width: "100%",
+ borderRadius: radius.lg,
+ overflow: "hidden",
+ ...shadows.button,
+ },
+ buttonGradient: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ paddingVertical: 18,
+ paddingHorizontal: spacing.xl,
+ gap: spacing.sm,
},
buttonText: {
- color: "#fff",
- fontSize: 16,
- fontWeight: "600",
+ fontSize: typography.sizes.body,
+ fontWeight: typography.weights.semibold,
+ color: colors.text.primary,
+ },
+ buttonArrow: {
+ fontSize: typography.sizes.h3,
+ color: colors.text.primary,
+ marginLeft: spacing.xs,
+ },
+ versionText: {
+ fontSize: typography.sizes.micro,
+ color: colors.text.tertiary,
+ marginTop: spacing.lg,
+ letterSpacing: typography.letterSpacing.extraWide,
+ textTransform: "uppercase",
},
});
diff --git a/mobile/app/profile.tsx b/mobile/app/profile.tsx
new file mode 100644
index 0000000..acbcac0
--- /dev/null
+++ b/mobile/app/profile.tsx
@@ -0,0 +1,676 @@
+import { Ionicons } from "@expo/vector-icons";
+import { LinearGradient } from "expo-linear-gradient";
+import { useLocalSearchParams, useRouter } from "expo-router";
+import { useCallback, useEffect, useState } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ FlatList,
+ Image,
+ Linking,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useAuth } from "~/contexts/AuthContext";
+import { colors, radius, spacing, typography } from "~/lib/theme";
+
+type ProfileData = {
+ did: string;
+ handle: string;
+ displayName: string;
+ description: string;
+ avatar: string;
+ banner: string;
+ followersCount: number;
+ followsCount: number;
+ postsCount: number;
+ isFollowing: boolean;
+ isFollowedBy: boolean;
+ followUri: string | null;
+};
+
+type ExternalEmbed = {
+ uri: string;
+ title: string;
+ description: string;
+ thumb: string;
+};
+
+type FeedPost = {
+ uri: string;
+ cid: string;
+ text: string;
+ createdAt: string;
+ likeCount: number;
+ repostCount: number;
+ replyCount: number;
+ images: string[];
+ external: ExternalEmbed | null;
+};
+
+export default function ProfileScreen() {
+ const router = useRouter();
+ const { did } = useLocalSearchParams<{ did: string }>();
+ const { agent } = useAuth();
+ const [profile, setProfile] = useState<ProfileData | null>(null);
+ const [posts, setPosts] = useState<FeedPost[]>([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isFollowLoading, setIsFollowLoading] = useState(false);
+
+ useEffect(() => {
+ if (!agent || !did) return;
+
+ const fetchProfile = async () => {
+ try {
+ const [profileRes, feedRes] = await Promise.all([
+ agent.getProfile({ actor: did }),
+ agent.getAuthorFeed({ actor: did, limit: 10, filter: "posts_no_replies" }),
+ ]);
+
+ const p = profileRes.data;
+ setProfile({
+ did: p.did,
+ handle: p.handle,
+ displayName: p.displayName ?? "",
+ description: p.description ?? "",
+ avatar: p.avatar ?? "",
+ banner: p.banner ?? "",
+ followersCount: p.followersCount ?? 0,
+ followsCount: p.followsCount ?? 0,
+ postsCount: p.postsCount ?? 0,
+ isFollowing: !!p.viewer?.following,
+ isFollowedBy: !!p.viewer?.followedBy,
+ followUri: p.viewer?.following ?? null,
+ });
+
+ const feedPosts: FeedPost[] = feedRes.data.feed
+ .filter((item) => item.post.record && !item.reason)
+ .map((item) => {
+ const record = item.post.record as { text?: string; createdAt?: string };
+ const images: string[] = [];
+ let external: ExternalEmbed | null = null;
+ const embed = item.post.embed;
+
+ if (embed) {
+ // Images
+ if ("images" in embed && Array.isArray(embed.images)) {
+ for (const img of embed.images) {
+ if (typeof img === "object" && img && "thumb" in img) {
+ images.push(img.thumb as string);
+ }
+ }
+ }
+
+ // Extract external link from embed or nested media embed
+ const extractExternal = (obj: unknown): ExternalEmbed | null => {
+ if (!obj || typeof obj !== "object") return null;
+ const e = obj as Record<string, unknown>;
+ if (e.uri && typeof e.uri === "string") {
+ return {
+ uri: e.uri,
+ title: (e.title as string) ?? "",
+ description: (e.description as string) ?? "",
+ thumb: (e.thumb as string) ?? "",
+ };
+ }
+ return null;
+ };
+
+ if ("external" in embed && embed.external) {
+ external = extractExternal(embed.external);
+ }
+ // recordWithMedia: external is nested under media.external
+ if ("media" in embed && embed.media) {
+ const media = embed.media as Record<string, unknown>;
+ if ("external" in media && media.external) {
+ external = extractExternal(media.external);
+ }
+ }
+ }
+
+ return {
+ uri: item.post.uri,
+ cid: item.post.cid,
+ text: record?.text ?? "",
+ createdAt: record?.createdAt ?? "",
+ likeCount: item.post.likeCount ?? 0,
+ repostCount: item.post.repostCount ?? 0,
+ replyCount: item.post.replyCount ?? 0,
+ images,
+ external,
+ };
+ });
+ setPosts(feedPosts);
+ } catch (e) {
+ console.warn("Failed to fetch profile:", e);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchProfile();
+ }, [agent, did]);
+
+ const handleFollow = useCallback(async () => {
+ if (!agent || !profile) return;
+ setIsFollowLoading(true);
+ try {
+ if (profile.isFollowing && profile.followUri) {
+ await agent.deleteFollow(profile.followUri);
+ setProfile((prev) => prev ? { ...prev, isFollowing: false, followUri: null } : prev);
+ } else {
+ const res = await agent.follow(profile.did);
+ setProfile((prev) => prev ? { ...prev, isFollowing: true, followUri: res.uri } : prev);
+ }
+ } catch (e) {
+ const message = e instanceof Error ? e.message : "Operation failed";
+ Alert.alert("Error", message);
+ } finally {
+ setIsFollowLoading(false);
+ }
+ }, [agent, profile]);
+
+ const handleOpenInBsky = useCallback(() => {
+ if (!profile) return;
+ Linking.openURL(`https://bsky.app/profile/${profile.handle}`);
+ }, [profile]);
+
+ const formatDate = (dateStr: string) => {
+ try {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffH = Math.floor(diffMs / (1000 * 60 * 60));
+ if (diffH < 1) return `${Math.max(1, Math.floor(diffMs / (1000 * 60)))}m`;
+ if (diffH < 24) return `${diffH}h`;
+ const diffD = Math.floor(diffH / 24);
+ if (diffD < 30) return `${diffD}d`;
+ return date.toLocaleDateString();
+ } catch {
+ return "";
+ }
+ };
+
+ const formatCount = (count: number) => {
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
+ return count.toString();
+ };
+
+ const renderPost = useCallback(
+ ({ item }: { item: FeedPost }) => (
+ <View style={styles.postCard}>
+ {item.text ? <Text style={styles.postText}>{item.text}</Text> : null}
+
+ {/* Images */}
+ {item.images.length > 0 && (
+ <View style={styles.postImages}>
+ {item.images.slice(0, 4).map((uri) => (
+ <Image key={uri} source={{ uri }} style={styles.postImage} />
+ ))}
+ </View>
+ )}
+
+ {/* External link card */}
+ {item.external && (
+ <TouchableOpacity
+ style={styles.linkCard}
+ onPress={() => Linking.openURL(item.external!.uri)}
+ activeOpacity={0.7}
+ >
+ {item.external.thumb ? (
+ <Image
+ source={{ uri: item.external.thumb }}
+ style={styles.linkThumb}
+ resizeMode="cover"
+ />
+ ) : null}
+ <View style={styles.linkContent}>
+ {item.external.title ? (
+ <Text style={styles.linkTitle} numberOfLines={2}>
+ {item.external.title}
+ </Text>
+ ) : null}
+ {item.external.description ? (
+ <Text style={styles.linkDescription} numberOfLines={2}>
+ {item.external.description}
+ </Text>
+ ) : null}
+ <Text style={styles.linkUrl} numberOfLines={1}>
+ {item.external.uri.replace(/^https?:\/\//, "")}
+ </Text>
+ </View>
+ </TouchableOpacity>
+ )}
+
+ <View style={styles.postStats}>
+ <View style={styles.postStatItem}>
+ <Ionicons name="chatbubble-outline" size={14} color={colors.text.tertiary} />
+ <Text style={styles.postStat}>{item.replyCount}</Text>
+ </View>
+ <View style={styles.postStatItem}>
+ <Ionicons name="repeat-outline" size={16} color={colors.text.tertiary} />
+ <Text style={styles.postStat}>{item.repostCount}</Text>
+ </View>
+ <View style={styles.postStatItem}>
+ <Ionicons name="heart-outline" size={14} color={colors.text.tertiary} />
+ <Text style={styles.postStat}>{item.likeCount}</Text>
+ </View>
+ <Text style={styles.postStatMuted}>{formatDate(item.createdAt)}</Text>
+ </View>
+ </View>
+ ),
+ [],
+ );
+
+ const renderHeader = useCallback(() => {
+ if (!profile) return null;
+ return (
+ <View>
+ {/* Banner */}
+ {profile.banner ? (
+ <Image source={{ uri: profile.banner }} style={styles.banner} resizeMode="cover" />
+ ) : (
+ <LinearGradient
+ colors={[...colors.gradient.accent]}
+ start={{ x: 0, y: 0 }}
+ end={{ x: 1, y: 1 }}
+ style={styles.banner}
+ />
+ )}
+
+ {/* Avatar + Follow */}
+ <View style={styles.avatarRow}>
+ {profile.avatar ? (
+ <Image source={{ uri: profile.avatar }} style={styles.avatar} />
+ ) : (
+ <View style={[styles.avatar, styles.avatarPlaceholder]} />
+ )}
+ <View style={styles.avatarActions}>
+ <TouchableOpacity
+ style={styles.openBskyButton}
+ onPress={handleOpenInBsky}
+ activeOpacity={0.7}
+ >
+ <Text style={styles.openBskyText}>Open in Bluesky</Text>
+ </TouchableOpacity>
+ <TouchableOpacity
+ style={[
+ styles.followButton,
+ profile.isFollowing && styles.followingButton,
+ isFollowLoading && styles.loadingButton,
+ ]}
+ onPress={handleFollow}
+ disabled={isFollowLoading}
+ activeOpacity={0.85}
+ >
+ {profile.isFollowing ? (
+ <Text style={styles.followingText}>Following</Text>
+ ) : (
+ <LinearGradient
+ colors={[...colors.gradient.button]}
+ start={{ x: 0, y: 0 }}
+ end={{ x: 1, y: 0 }}
+ style={styles.followGradient}
+ >
+ <Text style={styles.followText}>
+ {isFollowLoading ? "..." : "Follow"}
+ </Text>
+ </LinearGradient>
+ )}
+ </TouchableOpacity>
+ </View>
+ </View>
+
+ {/* Name / Handle / Follows you badge */}
+ <View style={styles.nameSection}>
+ <Text style={styles.displayName}>{profile.displayName || profile.handle}</Text>
+ <View style={styles.handleRow}>
+ <Text style={styles.handle}>@{profile.handle}</Text>
+ {profile.isFollowedBy && (
+ <View style={styles.followsYouBadge}>
+ <Text style={styles.followsYouText}>Follows you</Text>
+ </View>
+ )}
+ </View>
+ </View>
+
+ {/* Description */}
+ {profile.description ? (
+ <Text style={styles.description}>{profile.description}</Text>
+ ) : null}
+
+ {/* Stats */}
+ <View style={styles.statsRow}>
+ <View style={styles.statItem}>
+ <Text style={styles.statNumber}>{formatCount(profile.followsCount)}</Text>
+ <Text style={styles.statLabel}>Following</Text>
+ </View>
+ <View style={styles.statItem}>
+ <Text style={styles.statNumber}>{formatCount(profile.followersCount)}</Text>
+ <Text style={styles.statLabel}>Followers</Text>
+ </View>
+ <View style={styles.statItem}>
+ <Text style={styles.statNumber}>{formatCount(profile.postsCount)}</Text>
+ <Text style={styles.statLabel}>Posts</Text>
+ </View>
+ </View>
+
+ {/* Posts header */}
+ <View style={styles.postsHeader}>
+ <Text style={styles.postsHeaderText}>Recent Posts</Text>
+ <View style={styles.postsHeaderLine} />
+ </View>
+ </View>
+ );
+ }, [profile, isFollowLoading, handleFollow, handleOpenInBsky]);
+
+ if (isLoading) {
+ return (
+ <LinearGradient colors={[...colors.gradient.aurora]} style={styles.container}>
+ <View style={styles.loadingContainer}>
+ <ActivityIndicator size="large" color={colors.accent.cyan} />
+ </View>
+ </LinearGradient>
+ );
+ }
+
+ if (!profile) {
+ return (
+ <LinearGradient colors={[...colors.gradient.aurora]} style={styles.container}>
+ <View style={styles.loadingContainer}>
+ <Text style={styles.errorText}>Failed to load profile</Text>
+ </View>
+ </LinearGradient>
+ );
+ }
+
+ return (
+ <LinearGradient colors={[...colors.gradient.aurora]} style={styles.container}>
+ {/* Back button */}
+ <View style={styles.backBar}>
+ <TouchableOpacity
+ style={styles.backButton}
+ onPress={() => router.back()}
+ activeOpacity={0.7}
+ >
+ <Text style={styles.backButtonText}>← Back</Text>
+ </TouchableOpacity>
+ </View>
+
+ <FlatList
+ data={posts}
+ renderItem={renderPost}
+ keyExtractor={(item) => item.uri}
+ ListHeaderComponent={renderHeader}
+ ListEmptyComponent={
+ <View style={styles.emptyPosts}>
+ <Text style={styles.emptyPostsText}>No posts yet</Text>
+ </View>
+ }
+ contentContainerStyle={styles.listContent}
+ />
+ </LinearGradient>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ errorText: {
+ color: colors.text.secondary,
+ fontSize: typography.sizes.body,
+ },
+ backBar: {
+ paddingTop: 54,
+ paddingBottom: spacing.sm,
+ paddingHorizontal: spacing.lg,
+ },
+ backButton: {
+ alignSelf: "flex-start",
+ },
+ backButtonText: {
+ fontSize: typography.sizes.bodySmall,
+ fontWeight: typography.weights.medium,
+ color: colors.accent.cyan,
+ },
+ banner: {
+ width: "100%",
+ height: 140,
+ },
+ avatarRow: {
+ flexDirection: "row",
+ alignItems: "flex-end",
+ justifyContent: "space-between",
+ paddingHorizontal: spacing.lg,
+ marginTop: -36,
+ },
+ avatar: {
+ width: 72,
+ height: 72,
+ borderRadius: 36,
+ borderWidth: 3,
+ borderColor: colors.bg.primary,
+ },
+ avatarPlaceholder: {
+ backgroundColor: colors.bg.cardHover,
+ },
+ avatarActions: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ marginTop: spacing.xl,
+ },
+ openBskyButton: {
+ borderWidth: 1,
+ borderColor: colors.border.medium,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xs,
+ borderRadius: radius.full,
+ },
+ openBskyText: {
+ color: colors.text.secondary,
+ fontSize: typography.sizes.caption,
+ fontWeight: typography.weights.medium,
+ },
+ followButton: {
+ borderRadius: radius.full,
+ overflow: "hidden",
+ },
+ followGradient: {
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ },
+ followText: {
+ color: colors.text.primary,
+ fontSize: typography.sizes.bodySmall,
+ fontWeight: typography.weights.semibold,
+ },
+ followingButton: {
+ borderWidth: 1,
+ borderColor: colors.border.medium,
+ backgroundColor: "transparent",
+ },
+ followingText: {
+ color: colors.text.secondary,
+ fontSize: typography.sizes.bodySmall,
+ fontWeight: typography.weights.medium,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ },
+ loadingButton: {
+ opacity: 0.6,
+ },
+ nameSection: {
+ paddingHorizontal: spacing.lg,
+ marginTop: spacing.md,
+ },
+ displayName: {
+ fontSize: typography.sizes.h2,
+ fontWeight: typography.weights.bold,
+ color: colors.text.primary,
+ letterSpacing: typography.letterSpacing.tight,
+ },
+ handleRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ marginTop: 2,
+ },
+ handle: {
+ fontSize: typography.sizes.bodySmall,
+ color: colors.text.secondary,
+ },
+ followsYouBadge: {
+ backgroundColor: colors.bg.cardHover,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 2,
+ borderRadius: radius.sm,
+ },
+ followsYouText: {
+ fontSize: typography.sizes.micro,
+ color: colors.text.secondary,
+ fontWeight: typography.weights.medium,
+ },
+ description: {
+ fontSize: typography.sizes.caption,
+ color: colors.text.secondary,
+ lineHeight: 18,
+ paddingHorizontal: spacing.lg,
+ marginTop: spacing.md,
+ },
+ statsRow: {
+ flexDirection: "row",
+ paddingHorizontal: spacing.lg,
+ marginTop: spacing.lg,
+ gap: spacing.xl,
+ },
+ statItem: {
+ flexDirection: "row",
+ alignItems: "baseline",
+ gap: spacing.xs,
+ },
+ statNumber: {
+ fontSize: typography.sizes.body,
+ fontWeight: typography.weights.bold,
+ color: colors.text.primary,
+ },
+ statLabel: {
+ fontSize: typography.sizes.bodySmall,
+ color: colors.text.secondary,
+ },
+ postsHeader: {
+ paddingHorizontal: spacing.lg,
+ marginTop: spacing.xl,
+ marginBottom: spacing.md,
+ },
+ postsHeaderText: {
+ fontSize: typography.sizes.h3,
+ fontWeight: typography.weights.semibold,
+ color: colors.text.primary,
+ },
+ postsHeaderLine: {
+ height: 2,
+ width: 32,
+ backgroundColor: colors.accent.cyan,
+ borderRadius: 1,
+ marginTop: spacing.xs,
+ },
+ listContent: {
+ paddingBottom: spacing.xxxl,
+ },
+ postCard: {
+ marginHorizontal: spacing.lg,
+ marginBottom: spacing.md,
+ padding: spacing.lg,
+ backgroundColor: colors.bg.card,
+ borderRadius: radius.md,
+ borderWidth: 1,
+ borderColor: colors.border.subtle,
+ },
+ postText: {
+ fontSize: typography.sizes.bodySmall,
+ color: colors.text.primary,
+ lineHeight: 20,
+ },
+ postImages: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: spacing.sm,
+ marginTop: spacing.md,
+ },
+ postImage: {
+ width: 120,
+ height: 120,
+ borderRadius: radius.sm,
+ },
+ // External link card
+ linkCard: {
+ marginTop: spacing.md,
+ borderRadius: radius.sm,
+ borderWidth: 1,
+ borderColor: colors.border.subtle,
+ backgroundColor: colors.bg.input,
+ overflow: "hidden",
+ },
+ linkThumb: {
+ width: "100%",
+ height: 140,
+ backgroundColor: colors.bg.cardHover,
+ },
+ linkContent: {
+ padding: spacing.md,
+ },
+ linkTitle: {
+ fontSize: typography.sizes.bodySmall,
+ fontWeight: typography.weights.semibold,
+ color: colors.text.primary,
+ marginBottom: spacing.xs,
+ },
+ linkDescription: {
+ fontSize: typography.sizes.caption,
+ color: colors.text.secondary,
+ lineHeight: 16,
+ marginBottom: spacing.xs,
+ },
+ linkUrl: {
+ fontSize: typography.sizes.micro,
+ color: colors.text.tertiary,
+ },
+ postStats: {
+ flexDirection: "row",
+ alignItems: "center",
+ marginTop: spacing.md,
+ gap: spacing.lg,
+ },
+ postStatItem: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.xs,
+ },
+ postStat: {
+ fontSize: typography.sizes.caption,
+ color: colors.text.tertiary,
+ },
+ postStatMuted: {
+ fontSize: typography.sizes.caption,
+ color: colors.text.tertiary,
+ marginLeft: "auto",
+ },
+ emptyPosts: {
+ padding: spacing.xxl,
+ alignItems: "center",
+ },
+ emptyPostsText: {
+ fontSize: typography.sizes.bodySmall,
+ color: colors.text.secondary,
+ },
+});
diff --git a/mobile/app/results.tsx b/mobile/app/results.tsx
index 70970f5..b591bb4 100644
--- a/mobile/app/results.tsx
+++ b/mobile/app/results.tsx
@@ -1,18 +1,38 @@
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
-import { useCallback } from "react";
+import { useCallback, useMemo } from "react";
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from "react-native";
-import { UserCard } from "~/components/UserCard";
+import { UserGroupCard } from "~/components/UserGroupCard";
import { useAuth } from "~/contexts/AuthContext";
import { useScan } from "~/contexts/ScanContext";
import type { BskyUser } from "~/types";
import { colors, radius, spacing, typography } from "~/lib/theme";
+type UserGroup = {
+ key: string;
+ users: BskyUser[];
+};
+
export default function ResultsScreen() {
const router = useRouter();
const { agent } = useAuth();
const { matchedUsers, reset } = useScan();
+ // Group users by originalHandle
+ const groups = useMemo<UserGroup[]>(() => {
+ const map = new Map<string, BskyUser[]>();
+ for (const user of matchedUsers) {
+ const key = user.originalHandle || user.did;
+ const existing = map.get(key);
+ if (existing) {
+ existing.push(user);
+ } else {
+ map.set(key, [user]);
+ }
+ }
+ return Array.from(map.entries()).map(([key, users]) => ({ key, users }));
+ }, [matchedUsers]);
+
const handleFollow = useCallback(
async (user: BskyUser) => {
if (!agent) return;
@@ -27,8 +47,8 @@ export default function ResultsScreen() {
};
const renderItem = useCallback(
- ({ item }: { item: BskyUser }) => (
- <UserCard user={item} onFollow={handleFollow} />
+ ({ item }: { item: UserGroup }) => (
+ <UserGroupCard users={item.users} onFollow={handleFollow} />
),
[handleFollow],
);
@@ -40,13 +60,18 @@ export default function ResultsScreen() {
<Text style={styles.title}>
{matchedUsers.length} users found
</Text>
+ {groups.length !== matchedUsers.length && (
+ <Text style={styles.subtitle}>
+ {groups.length} X accounts matched
+ </Text>
+ )}
<View style={styles.accentLine} />
</View>
<FlatList
- data={matchedUsers}
+ data={groups}
renderItem={renderItem}
- keyExtractor={(item) => item.did}
+ keyExtractor={(item) => item.key}
contentContainerStyle={styles.list}
ListEmptyComponent={
<View style={styles.empty}>
@@ -88,6 +113,11 @@ const styles = StyleSheet.create({
color: colors.text.primary,
letterSpacing: typography.letterSpacing.tight,
},
+ subtitle: {
+ fontSize: typography.sizes.bodySmall,
+ color: colors.text.secondary,
+ marginTop: spacing.xs,
+ },
accentLine: {
height: 3,
width: 40,
@@ -96,7 +126,6 @@ const styles = StyleSheet.create({
marginTop: spacing.sm,
},
list: {
- flexGrow: 1,
paddingTop: spacing.sm,
paddingBottom: spacing.md,
},
diff --git a/mobile/app/scan.tsx b/mobile/app/scan.tsx
index 4973235..1b4385c 100644
--- a/mobile/app/scan.tsx
+++ b/mobile/app/scan.tsx
@@ -1,9 +1,11 @@
+import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import type { CrawledUserInfo } from "~/types";
import {
Animated,
+ Platform,
StyleSheet,
Text,
TouchableOpacity,
@@ -20,6 +22,13 @@ import { colors, radius, shadows, spacing, typography } from "~/lib/theme";
type Phase = "x_login" | "scanning" | "completed";
+// Use a real mobile browser user agent to prevent X from blocking WebView
+const MOBILE_USER_AGENT = Platform.select({
+ ios: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
+ android: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
+ default: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
+});
+
// Detect login completion: user is on x.com but NOT in login/auth flow
const X_LOGIN_FLOW_PATTERNS = [
/\/i\/flow\/login/,
@@ -37,6 +46,25 @@ const isOnXButNotLoginFlow = (url: string): boolean => {
const X_FOLLOWING_PATTERN = /^https:\/\/(x|twitter)\.com\/[^/]+\/(verified_follow|follow)/;
+// Injected script to poll for URL changes (SPA navigations don't trigger onNavigationStateChange)
+const URL_CHANGE_POLL_SCRIPT = `
+(function() {
+ if (window.__urlPollStarted) return;
+ window.__urlPollStarted = true;
+ var lastUrl = location.href;
+ setInterval(function() {
+ if (location.href !== lastUrl) {
+ lastUrl = location.href;
+ window.ReactNativeWebView.postMessage(JSON.stringify({
+ type: "url_change",
+ url: location.href
+ }));
+ }
+ }, 500);
+})();
+true;
+`;
+
export default function ScanScreen() {
const router = useRouter();
const { agent } = useAuth();
@@ -134,6 +162,9 @@ export default function ScanScreen() {
);
const handleLoadEnd = useCallback(() => {
+ // Always inject URL polling script to detect SPA navigations
+ webviewRef.current?.injectJavaScript(URL_CHANGE_POLL_SCRIPT);
+
// When following page loads, inject scrape script
if (phase === "scanning" && !hasStartedScan.current) {
hasStartedScan.current = true;
@@ -144,8 +175,42 @@ export default function ScanScreen() {
}
}, [phase]);
+ const phaseRef = useRef<Phase>("x_login");
+ // Keep phaseRef in sync with phase state
+ useEffect(() => {
+ phaseRef.current = phase;
+ }, [phase]);
+
+ const handleUrlChange = useCallback(
+ (url: string) => {
+ if (phaseRef.current !== "x_login") return;
+
+ if (isOnXButNotLoginFlow(url)) {
+ setPhase("scanning");
+ setStatus("scanning");
+ webviewRef.current?.injectJavaScript(
+ `window.location.href = ${JSON.stringify(X_FOLLOW_PAGE_URL)}; true;`,
+ );
+ }
+ },
+ [setStatus],
+ );
+
const handleMessage = useCallback(
async (event: WebViewMessageEvent) => {
+ let data: { type: string; [key: string]: unknown };
+ try {
+ data = JSON.parse(event.nativeEvent.data);
+ } catch {
+ return;
+ }
+
+ // Handle URL change from polling script
+ if (data.type === "url_change") {
+ handleUrlChange(data.url as string);
+ return;
+ }
+
if (!agent) return;
const message = parseExtractedUsers(event.nativeEvent.data);
@@ -166,7 +231,7 @@ export default function ScanScreen() {
setStatus("completed");
}
},
- [agent, processUsers, setStatus],
+ [agent, processUsers, setStatus, handleUrlChange],
);
const handleStop = () => {
@@ -203,15 +268,27 @@ export default function ScanScreen() {
)}
<WebView
ref={webviewRef}
- source={{ uri: X_LOGIN_URL }}
+ source={{ uri: "https://x.com" }}
style={styles.webview}
+ userAgent={MOBILE_USER_AGENT}
onNavigationStateChange={handleNavigationStateChange}
onLoadEnd={handleLoadEnd}
onMessage={handleMessage}
+ onError={(syntheticEvent) => {
+ const { nativeEvent } = syntheticEvent;
+ console.warn("WebView error:", nativeEvent);
+ }}
+ onHttpError={(syntheticEvent) => {
+ const { nativeEvent } = syntheticEvent;
+ console.warn("WebView HTTP error:", nativeEvent.statusCode, nativeEvent.url);
+ }}
javaScriptEnabled
domStorageEnabled
sharedCookiesEnabled
thirdPartyCookiesEnabled
+ allowsBackForwardNavigationGestures
+ setSupportMultipleWindows={false}
+ mediaPlaybackRequiresUserAction={false}
/>
</View>
@@ -248,9 +325,11 @@ export default function ScanScreen() {
</>
)}
<View style={[styles.scanIcon, isComplete && styles.scanIconComplete]}>
- <Text style={styles.scanIconText}>
- {isComplete ? "✓" : "⟳"}
- </Text>
+ <Ionicons
+ name={isComplete ? "checkmark-sharp" : "search-outline"}
+ size={26}
+ color={isComplete ? colors.status.success : colors.accent.cyan}
+ />
</View>
</View>
@@ -412,10 +491,6 @@ const styles = StyleSheet.create({
borderColor: colors.status.success,
shadowColor: colors.status.success,
},
- scanIconText: {
- fontSize: 24,
- color: colors.text.primary,
- },
title: {
fontSize: typography.sizes.h1,
fontWeight: typography.weights.bold,
diff --git a/mobile/app/x-login-guide.tsx b/mobile/app/x-login-guide.tsx
index 1dffb6b..34271ff 100644
--- a/mobile/app/x-login-guide.tsx
+++ b/mobile/app/x-login-guide.tsx
@@ -2,10 +2,12 @@ import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { useEffect, useRef } from "react";
import { Animated, StyleSheet, Text, TouchableOpacity, View } from "react-native";
+import { useAuth } from "~/contexts/AuthContext";
import { colors, radius, shadows, spacing, typography } from "~/lib/theme";
export default function XLoginGuideScreen() {
const router = useRouter();
+ const { logout, handle } = useAuth();
const fadeIn = useRef(new Animated.Value(0)).current;
const slideUp = useRef(new Animated.Value(30)).current;
@@ -82,6 +84,20 @@ export default function XLoginGuideScreen() {
<Text style={styles.buttonArrow}>→</Text>
</LinearGradient>
</TouchableOpacity>
+
+ {/* Switch account */}
+ <TouchableOpacity
+ style={styles.switchAccount}
+ onPress={() => {
+ logout();
+ router.replace("/");
+ }}
+ activeOpacity={0.7}
+ >
+ <Text style={styles.switchAccountText}>
+ Signed in as @{handle} · Switch account
+ </Text>
+ </TouchableOpacity>
</Animated.View>
</LinearGradient>
);
@@ -224,4 +240,12 @@ const styles = StyleSheet.create({
color: colors.text.primary,
marginLeft: spacing.xs,
},
+ switchAccount: {
+ alignItems: "center",
+ marginTop: spacing.xl,
+ },
+ switchAccountText: {
+ fontSize: typography.sizes.caption,
+ color: colors.text.tertiary,
+ },
});
diff --git a/mobile/components/TypeaheadDropdown.tsx b/mobile/components/TypeaheadDropdown.tsx
new file mode 100644
index 0000000..d900987
--- /dev/null
+++ b/mobile/components/TypeaheadDropdown.tsx
@@ -0,0 +1,190 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ ActivityIndicator,
+ Image,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { BSKY_DOMAIN } from "~/lib/constants";
+import { colors, radius, spacing, typography } from "~/lib/theme";
+
+const DEBOUNCE_MS = 300;
+const MIN_QUERY_LENGTH = 2;
+const SEARCH_LIMIT = 8;
+
+type Actor = {
+ did: string;
+ handle: string;
+ displayName?: string;
+ avatar?: string;
+};
+
+type Props = {
+ query: string;
+ visible: boolean;
+ onSelect: (handle: string) => void;
+};
+
+async function searchActors(q: string): Promise<Actor[]> {
+ try {
+ const res = await fetch(
+ `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=${SEARCH_LIMIT}`,
+ );
+ if (!res.ok) return [];
+ const data = await res.json();
+ return data.actors ?? [];
+ } catch {
+ return [];
+ }
+}
+
+export function TypeaheadDropdown({ query, visible, onSelect }: Props) {
+ const [suggestions, setSuggestions] = useState<Actor[]>([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const requestCounterRef = useRef(0);
+
+ useEffect(() => {
+ if (!visible || query.length < MIN_QUERY_LENGTH) {
+ setSuggestions([]);
+ setIsSearching(false);
+ return;
+ }
+
+ setIsSearching(true);
+
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+
+ debounceRef.current = setTimeout(async () => {
+ const requestId = ++requestCounterRef.current;
+ try {
+ const actors = await searchActors(query);
+ if (requestId === requestCounterRef.current) {
+ setSuggestions(actors);
+ }
+ } catch {
+ if (requestId === requestCounterRef.current) {
+ setSuggestions([]);
+ }
+ } finally {
+ if (requestId === requestCounterRef.current) {
+ setIsSearching(false);
+ }
+ }
+ }, DEBOUNCE_MS);
+
+ return () => {
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+ };
+ }, [query, visible]);
+
+ if (!visible || (query.length < MIN_QUERY_LENGTH && suggestions.length === 0)) {
+ return null;
+ }
+
+ return (
+ <View style={styles.container}>
+ <ScrollView
+ style={styles.list}
+ keyboardShouldPersistTaps="handled"
+ nestedScrollEnabled
+ >
+ {isSearching && suggestions.length === 0 && (
+ <View style={styles.loadingRow}>
+ <ActivityIndicator size="small" color={colors.accent.cyan} />
+ </View>
+ )}
+ {suggestions.map((actor) => (
+ <TouchableOpacity
+ key={actor.did}
+ style={styles.row}
+ onPress={() => onSelect(actor.handle)}
+ activeOpacity={0.7}
+ >
+ {actor.avatar ? (
+ <Image source={{ uri: actor.avatar }} style={styles.avatar} />
+ ) : (
+ <View style={[styles.avatar, styles.avatarPlaceholder]} />
+ )}
+ <View style={styles.info}>
+ <Text style={styles.displayName} numberOfLines={1}>
+ {actor.displayName || actor.handle}
+ </Text>
+ <Text style={styles.handle} numberOfLines={1}>
+ @{actor.handle}
+ </Text>
+ </View>
+ </TouchableOpacity>
+ ))}
+ {!isSearching && suggestions.length === 0 && query.length >= MIN_QUERY_LENGTH && (
+ <View style={styles.emptyRow}>
+ <Text style={styles.emptyText}>No results</Text>
+ </View>
+ )}
+ </ScrollView>
+ </View>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ backgroundColor: colors.bg.secondary,
+ borderRadius: radius.md,
+ borderWidth: 1,
+ borderColor: colors.border.medium,
+ overflow: "hidden",
+ marginBottom: spacing.md,
+ marginTop: -spacing.sm,
+ },
+ list: {
+ maxHeight: 220,
+ },
+ loadingRow: {
+ paddingVertical: spacing.lg,
+ alignItems: "center",
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.md,
+ gap: spacing.md,
+ },
+ avatar: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ },
+ avatarPlaceholder: {
+ backgroundColor: colors.bg.cardHover,
+ },
+ info: {
+ flex: 1,
+ overflow: "hidden",
+ },
+ displayName: {
+ fontSize: typography.sizes.bodySmall,
+ fontWeight: typography.weights.semibold,
+ color: colors.text.primary,
+ },
+ handle: {
+ fontSize: typography.sizes.caption,
+ color: colors.text.secondary,
+ marginTop: 1,
+ },
+ emptyRow: {
+ paddingVertical: spacing.lg,
+ alignItems: "center",
+ },
+ emptyText: {
+ fontSize: typography.sizes.caption,
+ color: colors.text.tertiary,
+ },
+});
diff --git a/mobile/components/UserCard.tsx b/mobile/components/UserCard.tsx
index df4038f..0dfec17 100644
--- a/mobile/components/UserCard.tsx
+++ b/mobile/components/UserCard.tsx
@@ -1,4 +1,5 @@
import { LinearGradient } from "expo-linear-gradient";
+import { useRouter } from "expo-router";
import { useState } from "react";
import { Alert, Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import type { BskyUser } from "~/types";
@@ -16,6 +17,7 @@ const MATCH_TYPE_LABELS: Record<string, { label: string; color: string }> = {
};
export function UserCard({ user, onFollow }: Props) {
+ const router = useRouter();
const [isFollowing, setIsFollowing] = useState(user.isFollowing);
const [isLoading, setIsLoading] = useState(false);
@@ -35,7 +37,11 @@ export function UserCard({ user, onFollow }: Props) {
};
return (
- <View style={styles.container}>
+ <TouchableOpacity
+ style={styles.container}
+ onPress={() => router.push({ pathname: "/profile", params: { did: user.did } })}
+ activeOpacity={0.7}
+ >
<View style={styles.avatarRow}>
{user.originalAvatar ? (
<Image source={{ uri: user.originalAvatar }} style={styles.avatar} />
@@ -94,7 +100,7 @@ export function UserCard({ user, onFollow }: Props) {
</LinearGradient>
</TouchableOpacity>
)}
- </View>
+ </TouchableOpacity>
);
}
diff --git a/mobile/components/UserGroupCard.tsx b/mobile/components/UserGroupCard.tsx
new file mode 100644
index 0000000..d43fa25
--- /dev/null
+++ b/mobile/components/UserGroupCard.tsx
@@ -0,0 +1,370 @@
+import { Ionicons } from "@expo/vector-icons";
+import { LinearGradient } from "expo-linear-gradient";
+import { useRouter } from "expo-router";
+import { useState } from "react";
+import { Alert, Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
+import type { BskyUser } from "~/types";
+import { colors, radius, spacing, typography } from "~/lib/theme";
+
+type Props = {
+ users: BskyUser[];
+ onFollow: (user: BskyUser) => Promise<void>;
+};
+
+const MATCH_TYPE_LABELS: Record<string, { label: string; color: string }> = {
+ handle: { label: "Handle match", color: colors.match.handle },
+ display_name: { label: "Display name", color: colors.match.display_name },
+ description: { label: "Bio match", color: colors.match.description },
+};
+
+function SingleUserRow({
+ user,
+ onFollow,
+}: { user: BskyUser; onFollow: (user: BskyUser) => Promise<void> }) {
+ const router = useRouter();
+ const [isFollowing, setIsFollowing] = useState(user.isFollowing);
+ const [isLoading, setIsLoading] = useState(false);
+ const matchInfo = MATCH_TYPE_LABELS[user.matchType];
+
+ const handleFollow = async () => {
+ setIsLoading(true);
+ try {
+ await onFollow(user);
+ setIsFollowing(true);
+ } catch (e) {
+ const message = e instanceof Error ? e.message : "Follow failed";
+ Alert.alert("Error", message);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <TouchableOpacity
+ style={styles.userRow}
+ onPress={() => router.push({ pathname: "/profile", params: { did: user.did } })}
+ activeOpacity={0.7}
+ >
+ {user.avatar ? (
+ <Image source={{ uri: user.avatar }} style={[styles.avatar, styles.bskyAvatar]} />
+ ) : (
+ <View style={[styles.avatar, styles.avatarPlaceholder]} />
+ )}
+ <View style={styles.info}>
+ <Text style={styles.displayName} numberOfLines={1}>
+ {user.displayName || …
旧設計 (2026-04-06) のサーバーサイドプロキシ案を置き換え。公式パッケージ @atproto/oauth-client-expo がネイティブモジュールで DPoP を処理するため、 サーバー側の追加実装が不要になり、ブラウザ拡張版と同じ OAuth コアを共有 できる構成に変更。
- reflect Codex review: restore(sub,false), AtpAgent retention, iOS-only scope, legacy scheme removal - add step-by-step implementation plan under specs/plans/
…ization
- use base Agent (not AtpAgent) with OAuthSession duck-typed as SessionManager
- return { agent, sub, handle } — handle resolved via getProfile
- OAuthLoginError centralizes error codes; cancelled is a distinct code
- returns { agent: Agent, handle } union-compatible with app-password path
- OAuth restore fetches profile for handle; failure revokes and clears
- sub empty guard short-circuits before calling the OAuth client
…Agent - sub empty guard + revoke-on-failure on session restore - logout branches on OAuth to revoke MMKV session - handle comes from restoreAgent/loginWithOAuth (no more agent.session access) - widen scan/fuzzySearch agent param to base Agent so OAuth and app-password share
- restoreAgent: preserve session on transient getProfile failure (don't revoke on network errors) - bskyAgent.test: add app-password branch tests (success, resumeSession failure, createAgentWithAppPassword) - AuthContext: drop redundant sub guard (loginWithOAuth already enforces it) - bskyOAuth: add TODO about brittle English error string matching (re-examine when upgrading past 0.0.10)
- ExpoOAuthClient: pass handleResolver="https://bsky.social" (XRPC resolveHandle) package requires identityResolver or handleResolver at construction time - redirect URI scheme: dev.sky-follower-bridge.mobile -> dev.sky-follower-bridge.server AT Protocol OAuth spec requires private-use scheme to be the reverse-FQDN of the client_id host (server.sky-follower-bridge.dev) - bskyOAuth: add temporary [oauth] diagnostic logs (will be removed after verify)
@atproto/oauth-client uses AbortSignal.timeout in verifyIssuer during the OAuth callback path, but Hermes (RN 0.81) does not implement this static method and the package's bundled polyfill does not cover it. Add a local polyfill imported before @atproto/oauth-client-expo so the symbol exists when the OAuth client constructs.
diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 9cf47b4..6c36f96 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -1797,6 +1797,43 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -3981,7 +4018,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -5192,7 +5229,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -5539,6 +5576,21 @@ "expo": "*" } }, + "node_modules/expo-font": { + "version": "55.0.6", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-55.0.6.tgz", + "integrity": "sha512-x9czUA3UQWjIwa0ZUEs/eWJNqB4mAue/m4ltESlNPLZhHL0nWWqIfsyHmklTLFH7mVfcHSJvew6k+pR2FE1zVw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-linear-gradient": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz", @@ -8923,6 +8975,26 @@ "ws": "^7" } }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
diff --git a/mobile/app.json b/mobile/app.json index 9b1761a..8b87625 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -5,18 +5,26 @@ "version": "1.0.0", "scheme": "dev.sky-follower-bridge.server", "platforms": ["ios", "android"], - "plugins": [ - "expo-router", - "expo-secure-store" - ], + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#0a0a0f" + }, + "plugins": ["expo-router", "expo-secure-store"], "web": { - "bundler": "metro" + "bundler": "metro", + "favicon": "./assets/favicon.png" }, "ios": { "bundleIdentifier": "dev.sky-follower-bridge.mobile" }, "android": { - "package": "dev.sky_follower_bridge.mobile" + "package": "dev.sky_follower_bridge.mobile", + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + } } } } diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 6d7e807..94966c1 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from "react"; import { ActivityIndicator, Animated, + Image, StyleSheet, Text, TouchableOpacity, @@ -58,18 +59,28 @@ export default function WelcomeScreen() { if (isLoading) { return ( - <LinearGradient colors={[...colors.gradient.aurora]} style={styles.container}> + <LinearGradient + colors={[...colors.gradient.aurora]} + style={styles.container} + > <ActivityIndicator size="large" color={colors.accent.cyan} /> </LinearGradient> ); } return ( - <LinearGradient colors={[...colors.gradient.aurora]} style={styles.container}> + <LinearGradient + colors={[...colors.gradient.aurora]} + style={styles.container} + > {/* Aurora glow effect */} <Animated.View style={[styles.glowOrb, { opacity: glowOpacity }]}> <LinearGradient - colors={["rgba(0, 133, 255, 0.15)", "rgba(0, 194, 255, 0.08)", "transparent"]} + colors={[ + "rgba(0, 133, 255, 0.15)", + "rgba(0, 194, 255, 0.08)", + "transparent", + ]} style={styles.glowGradient} start={{ x: 0.5, y: 0 }} end={{ x: 0.5, y: 1 }} @@ -78,17 +89,14 @@ export default function WelcomeScreen() { <View style={styles.content}> {/* Logo / Brand */} - <Animated.View style={[styles.logoContainer, { transform: [{ scale: logoScale }] }]}> - <View style={styles.iconCircle}> - <LinearGradient - colors={[...colors.gradient.accent]} - start={{ x: 0, y: 0 }} - end={{ x: 1, y: 1 }} - style={styles.iconGradient} - > - <Text style={styles.iconText}>SFB</Text> - </LinearGradient> - </View> + <Animated.View + style={[styles.logoContainer, { transform: [{ scale: logoScale }] }]} + > + <Image + source={require("~/assets/icon.png")} + style={styles.logoImage} + resizeMode="contain" + /> </Animated.View> <Animated.View @@ -157,23 +165,9 @@ const styles = StyleSheet.create({ logoContainer: { marginBottom: spacing.xxl, }, - iconCircle: { - width: 88, - height: 88, - borderRadius: 44, - overflow: "hidden", - ...shadows.glow, - }, - iconGradient: { - flex: 1, - justifyContent: "center", - alignItems: "center", - }, - iconText: { - fontSize: typography.sizes.h2, - fontWeight: typography.weights.heavy, - color: colors.text.inverse, - letterSpacing: typography.letterSpacing.wide, + logoImage: { + width: 112, + height: 112, }, textContainer: { alignItems: "center", diff --git a/mobile/assets/adaptive-icon.png b/mobile/assets/adaptive-icon.png index 03d6f6b..e9a1811 100644 Binary files a/mobile/assets/adaptive-icon.png and b/mobile/assets/adaptive-icon.png differ diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png index e75f697..e9a1811 100644 Binary files a/mobile/assets/favicon.png and b/mobile/assets/favicon.png differ diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png index a0b1526..e9a1811 100644 Binary files a/mobile/assets/icon.png and b/mobile/assets/icon.png differ diff --git a/mobile/assets/splash-icon.png b/mobile/assets/splash-icon.png index 03d6f6b..e9a1811 100644 Binary files a/mobile/assets/splash-icon.png and b/mobile/assets/splash-icon.png differ
diff --git a/server/deploy b/server/deploy new file mode 100644 index 0000000..8c3a697 --- /dev/null +++ b/server/deploy @@ -0,0 +1,30 @@ +> wrangler deploy --minify + + + ⛅️ wrangler 3.105.1 (update available 4.81.1) +------------------------------------------------------ +-------------------------------------------------------- + +▲ [WARNING] The version of Wrangler you are using is n +▲ [WARNING] The version of Wrangler you are using is now out-of-date. + + Please update to the latest version to prevent criti + Please update to the latest version to prevent critical errors. + Run `npm install --save-dev wrangler@4` to update to + Run `npm install --save-dev wrangler@4` to update to the latest version. + After installation, run Wrangler with `npx wrangler` + After installation, run Wrangler with `npx wrangler`. + + +Total Upload: 1985.14 KiB / gzip: 789.83 KiB +Worker Startup Time: 34 ms +Your worker has access to the following bindings: +- Vars: + - OAUTH_REDIRECT_URI: "https://server.sky-follower-b + - OAUTH_REDIRECT_URI: "https://server.sky-follower-bridge.de..TH - OAUTH_REDIRECT_URI: "https://server.sky-follower-bridge.de..N_REDIRECT_URIS: "https://behhbpbpmailcnfbjagknjngnfdoj..." + - OAUTH_EXTENSION_REDIRECT_URI: "https://behhbpbpmai + - OAUTH_EXTENSION_REDIRECT_URI: "https://behhbpbpmailcnfbjagknjngnfdoj..." +Uploaded lp (3.87 sec) +Deployed lp triggers (0.25 sec) + https://lp.ba068082.workers.dev +Current Version ID: 8f26b59a-9dc7-4618-a600-a8428f9378 diff --git a/tasks/lessons.md b/tasks/lessons.md new file mode 100644 index 0000000..1aeb5ad --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,31 @@ +# Lessons + +## 2026-04-10: spec の前提を検証するタイミング + +**状況:** `specs/2026-04-10-mobile-oauth-expo-design.md` の決定事項 #7 「`AtpAgent` を維持して `fetch: session.fetchHandler` を注入」を計画に落とし込んだが、Task 8 で `@atproto/oauth-client-expo@0.0.10` の実型定義を確認したところ、以下が判明: + +1. `OAuthSession.server` は `OAuthServerAgent` で、`.issuer` 直接取得はできない (`serverMetadata.issuer` が正しい) +2. `OAuthSession.fetchHandler` は `(pathname: string, init?) => Promise<Response>` — 標準 `fetch(url, init)` とは別シグネチャ +3. `AtpAgent` のコンストラクタは `AtpAgentOptions | CredentialSession` のみ受理し OAuthSession を直接注入できない +4. `@atproto/api` 自身が `AtpAgent is deprecated, use Agent with CredentialSession instead` と JSDoc 警告 +5. 実際にブラウザ拡張版 (`src/lib/bskyClient.ts:120`) も `new Agent(oauthSession)` (基底 `Agent`) を使用 + +**何が問題だったか:** +spec を書いた時点で `OAuthSession` の実 API を確認せず「きっと `session.fetchHandler` が標準 fetch だろう」と推測で書いた。推測をそのまま計画に落とし込み、TDD テストまで書いてから食い違いが発覚するのはコスト高。 + +**ルール:** +外部パッケージの API に依存する設計判断 (型・プロパティ名・コンストラクタシグネチャ) は、**spec 段階で一度でいいから node_modules の `.d.ts` を読むか、パッケージのソースを開いて実在を確認する**。「多分こうだろう」で spec に書かない。特に `0.0.x` の若いパッケージは推測が当たらない前提で臨む。 + +**How to apply:** +次回パッケージ統合 spec を書くときは、「確定した設計判断」セクションに `✅ 型定義確認済み` のようなチェックマークを付けるか、もしくは spec 本体で該当プロパティ名を `.d.ts` から引用する。 + +## 2026-04-10: gitignored ディレクトリへの spec 記述に注意 + +**状況:** spec が `mobile/ios/SkyFollowerBridge/Info.plist` の手動編集をタスクとして指示していたが、`mobile/.gitignore` に `/ios` があり `mobile/ios/` はそもそも tracked されていない。`expo prebuild` で毎回再生成される扱い。 + +**ルール:** +タスク対象ファイルを spec に書く前に `git ls-files <path>` でトラック状況を確認する。gitignored ファイルへの変更は commit できず、spec のタスク構造が崩れる。tracked されていないなら、変更はソース (この場合 `mobile/app.json` の `scheme`) 側に寄せる。 + +**How to apply:** +- `mobile/ios/**`, `mobile/android/**`, `build/**`, `node_modules/**` など generated/ignored なパスに touch するタスクは spec 段階で赤信号 +- generated ファイルの状態を変えたいなら、生成元 (config, template) を変えるタスクに置き換える
diff --git a/mobile/app/profile.tsx b/mobile/app/profile.tsx
index acbcac0..1ec785c 100644
--- a/mobile/app/profile.tsx
+++ b/mobile/app/profile.tsx
@@ -2,6 +2,9 @@ import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
+
+const BSKY_DEFAULT_AVATAR_URI =
+ "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTAiIGhlaWdodD0iOTAiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJub25lIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMiIgZmlsbD0iIzAwNzBmZiI+PC9jaXJjbGU+PGNpcmNsZSBjeD0iMTIiIGN5PSI5LjUiIHI9IjMuNSIgZmlsbD0iI2ZmZiI+PC9jaXJjbGU+PHBhdGggc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBmaWxsPSIjZmZmIiBkPSJNIDEyLjA1OCAyMi43ODQgQyA5LjQyMiAyMi43ODQgNy4wMDcgMjEuODM2IDUuMTM3IDIwLjI2MiBDIDUuNjY3IDE3Ljk4OCA4LjUzNCAxNi4yNSAxMS45OSAxNi4yNSBDIDE1LjQ5NCAxNi4yNSAxOC4zOTEgMTguMDM2IDE4Ljg2NCAyMC4zNTcgQyAxNy4wMSAyMS44NzQgMTQuNjQgMjIuNzg0IDEyLjA1OCAyMi43ODQgWiI+PC9wYXRoPjwvc3ZnPg==";
import {
ActivityIndicator,
Alert,
@@ -283,11 +286,10 @@ export default function ProfileScreen() {
{/* Avatar + Follow */}
<View style={styles.avatarRow}>
- {profile.avatar ? (
- <Image source={{ uri: profile.avatar }} style={styles.avatar} />
- ) : (
- <View style={[styles.avatar, styles.avatarPlaceholder]} />
- )}
+ <Image
+ source={{ uri: profile.avatar || BSKY_DEFAULT_AVATAR_URI }}
+ style={styles.avatar}
+ />
<View style={styles.avatarActions}>
<TouchableOpacity
style={styles.openBskyButton}
diff --git a/mobile/app/scan.tsx b/mobile/app/scan.tsx
index 1b4385c..464a656 100644
--- a/mobile/app/scan.tsx
+++ b/mobile/app/scan.tsx
@@ -46,6 +46,8 @@ const isOnXButNotLoginFlow = (url: string): boolean => {
const X_FOLLOWING_PATTERN = /^https:\/\/(x|twitter)\.com\/[^/]+\/(verified_follow|follow)/;
+const IN_WEBVIEW_URL_PATTERN = /^https:\/\/([^.]+\.)*(x|twitter)\.com(\/|$)/;
+
// Injected script to poll for URL changes (SPA navigations don't trigger onNavigationStateChange)
const URL_CHANGE_POLL_SCRIPT = `
(function() {
@@ -234,6 +236,18 @@ export default function ScanScreen() {
[agent, processUsers, setStatus, handleUrlChange],
);
+ // Block any top-level navigation that tries to leave x.com/twitter.com.
+ // Without this, cross-site redirects (especially non-http schemes like
+ // app deep links) fall through to RN WebView's default handler, which
+ // calls Linking.openURL and kicks the user out to Safari.
+ const handleShouldStartLoadWithRequest = useCallback(
+ (request: { url: string }): boolean => {
+ if (request.url === "about:blank") return true;
+ return IN_WEBVIEW_URL_PATTERN.test(request.url);
+ },
+ [],
+ );
+
const handleStop = () => {
webviewRef.current?.injectJavaScript(
'window.postMessage(JSON.stringify({type:"stop_scan"})); true;',
@@ -268,9 +282,14 @@ export default function ScanScreen() {
)}
<WebView
ref={webviewRef}
- source={{ uri: "https://x.com" }}
+ // Start on the login flow URL. Starting at https://x.com/ caused
+ // isOnXButNotLoginFlow() to match on the initial navigation
+ // callback, flipping phase to "scanning" before the user even
+ // saw the login page.
+ source={{ uri: X_LOGIN_URL }}
style={styles.webview}
userAgent={MOBILE_USER_AGENT}
+ onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
onNavigationStateChange={handleNavigationStateChange}
onLoadEnd={handleLoadEnd}
onMessage={handleMessage}
diff --git a/mobile/components/TypeaheadDropdown.tsx b/mobile/components/TypeaheadDropdown.tsx
index d900987..02973f9 100644
--- a/mobile/components/TypeaheadDropdown.tsx
+++ b/mobile/components/TypeaheadDropdown.tsx
@@ -8,6 +8,9 @@ import {
TouchableOpacity,
View,
} from "react-native";
+
+const BSKY_DEFAULT_AVATAR_URI =
+ "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTAiIGhlaWdodD0iOTAiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJub25lIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMiIgZmlsbD0iIzAwNzBmZiI+PC9jaXJjbGU+PGNpcmNsZSBjeD0iMTIiIGN5PSI5LjUiIHI9IjMuNSIgZmlsbD0iI2ZmZiI+PC9jaXJjbGU+PHBhdGggc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBmaWxsPSIjZmZmIiBkPSJNIDEyLjA1OCAyMi43ODQgQyA5LjQyMiAyMi43ODQgNy4wMDcgMjEuODM2IDUuMTM3IDIwLjI2MiBDIDUuNjY3IDE3Ljk4OCA4LjUzNCAxNi4yNSAxMS45OSAxNi4yNSBDIDE1LjQ5NCAxNi4yNSAxOC4zOTEgMTguMDM2IDE4Ljg2NCAyMC4zNTcgQyAxNy4wMSAyMS44NzQgMTQuNjQgMjIuNzg0IDEyLjA1OCAyMi43ODQgWiI+PC9wYXRoPjwvc3ZnPg==";
import { BSKY_DOMAIN } from "~/lib/constants";
import { colors, radius, spacing, typography } from "~/lib/theme";
@@ -108,11 +111,10 @@ export function TypeaheadDropdown({ query, visible, onSelect }: Props) {
onPress={() => onSelect(actor.handle)}
activeOpacity={0.7}
>
- {actor.avatar ? (
- <Image source={{ uri: actor.avatar }} style={styles.avatar} />
- ) : (
- <View style={[styles.avatar, styles.avatarPlaceholder]} />
- )}
+ <Image
+ source={{ uri: actor.avatar || BSKY_DEFAULT_AVATAR_URI }}
+ style={styles.avatar}
+ />
<View style={styles.info}>
<Text style={styles.displayName} numberOfLines={1}>
{actor.displayName || actor.handle}
diff --git a/mobile/components/UserCard.tsx b/mobile/components/UserCard.tsx
index 0dfec17..3fa52df 100644
--- a/mobile/components/UserCard.tsx
+++ b/mobile/components/UserCard.tsx
@@ -5,6 +5,9 @@ import { Alert, Image, StyleSheet, Text, TouchableOpacity, View } from "react-na
import type { BskyUser } from "~/types";
import { colors, radius, spacing, typography } from "~/lib/theme";
+const BSKY_DEFAULT_AVATAR_URI =
+ "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTAiIGhlaWdodD0iOTAiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJub25lIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMiIgZmlsbD0iIzAwNzBmZiI+PC9jaXJjbGU+PGNpcmNsZSBjeD0iMTIiIGN5PSI5LjUiIHI9IjMuNSIgZmlsbD0iI2ZmZiI+PC9jaXJjbGU+PHBhdGggc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBmaWxsPSIjZmZmIiBkPSJNIDEyLjA1OCAyMi43ODQgQyA5LjQyMiAyMi43ODQgNy4wMDcgMjEuODM2IDUuMTM3IDIwLjI2MiBDIDUuNjY3IDE3Ljk4OCA4LjUzNCAxNi4yNSAxMS45OSAxNi4yNSBDIDE1LjQ5NCAxNi4yNSAxOC4zOTEgMTguMDM2IDE4Ljg2NCAyMC4zNTcgQyAxNy4wMSAyMS44NzQgMTQuNjQgMjIuNzg0IDEyLjA1OCAyMi43ODQgWiI+PC9wYXRoPjwvc3ZnPg==";
+
type Props = {
user: BskyUser;
onFollow: (user: BskyUser) => Promise<void>;
@@ -49,14 +52,10 @@ export function UserCard({ user, onFollow }: Props) {
<View style={[styles.avatar, styles.avatarPlaceholder]} />
)}
<Text style={styles.arrow}>→</Text>
- {user.avatar ? (
- <Image
- source={{ uri: user.avatar }}
- style={[styles.avatar, styles.bskyAvatar]}
- />
- ) : (
- <View style={[styles.avatar, styles.avatarPlaceholder]} />
- )}
+ <Image
+ source={{ uri: user.avatar || BSKY_DEFAULT_AVATAR_URI }}
+ style={[styles.avatar, styles.bskyAvatar]}
+ />
</View>
<View style={styles.info}>
diff --git a/mobile/components/UserGroupCard.tsx b/mobile/components/UserGroupCard.tsx
index d43fa25..9ede14d 100644
--- a/mobile/components/UserGroupCard.tsx
+++ b/mobile/components/UserGroupCard.tsx
@@ -6,6 +6,9 @@ import { Alert, Image, StyleSheet, Text, TouchableOpacity, View } from "react-na
import type { BskyUser } from "~/types";
import { colors, radius, spacing, typography } from "~/lib/theme";
+const BSKY_DEFAULT_AVATAR_URI =
+ "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTAiIGhlaWdodD0iOTAiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJub25lIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMiIgZmlsbD0iIzAwNzBmZiI+PC9jaXJjbGU+PGNpcmNsZSBjeD0iMTIiIGN5PSI5LjUiIHI9IjMuNSIgZmlsbD0iI2ZmZiI+PC9jaXJjbGU+PHBhdGggc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBmaWxsPSIjZmZmIiBkPSJNIDEyLjA1OCAyMi43ODQgQyA5LjQyMiAyMi43ODQgNy4wMDcgMjEuODM2IDUuMTM3IDIwLjI2MiBDIDUuNjY3IDE3Ljk4OCA4LjUzNCAxNi4yNSAxMS45OSAxNi4yNSBDIDE1LjQ5NCAxNi4yNSAxOC4zOTEgMTguMDM2IDE4Ljg2NCAyMC4zNTcgQyAxNy4wMSAyMS44NzQgMTQuNjQgMjIuNzg0IDEyLjA1OCAyMi43ODQgWiI+PC9wYXRoPjwvc3ZnPg==";
+
type Props = {
users: BskyUser[];
onFollow: (user: BskyUser) => Promise<void>;
@@ -45,11 +48,10 @@ function SingleUserRow({
onPress={() => router.push({ pathname: "/profile", params: { did: user.did } })}
activeOpacity={0.7}
>
- {user.avatar ? (
- <Image source={{ uri: user.avatar }} style={[styles.avatar, styles.bskyAvatar]} />
- ) : (
- <View style={[styles.avatar, styles.avatarPlaceholder]} />
- )}
+ <Image
+ source={{ uri: user.avatar || BSKY_DEFAULT_AVATAR_URI }}
+ style={[styles.avatar, styles.bskyAvatar]}
+ />
<View style={styles.info}>
<Text style={styles.displayName} numberOfLines={1}>
{user.displayName || user.handle}
@@ -132,11 +134,10 @@ export function UserGroupCard({ users, onFollow }: Props) {
<View style={[styles.avatar, styles.avatarPlaceholder]} />
)}
<Text style={styles.arrow}>→</Text>
- {user.avatar ? (
- <Image source={{ uri: user.avatar }} style={[styles.avatar, styles.bskyAvatar]} />
- ) : (
- <View style={[styles.avatar, styles.avatarPlaceholder]} />
- )}
+ <Image
+ source={{ uri: user.avatar || BSKY_DEFAULT_AVATAR_URI }}
+ style={[styles.avatar, styles.bskyAvatar]}
+ />
</View>
<View style={styles.info}>
<Text style={styles.displayName} numberOfLines={1}>
@@ -200,7 +201,7 @@ export function UserGroupCard({ users, onFollow }: Props) {
{users.slice(0, 3).map((u, i) => (
<Image
key={u.did}
- source={{ uri: u.avatar }}
+ source={{ uri: u.avatar || BSKY_DEFAULT_AVATAR_URI }}
style={[
styles.avatar,
styles.bskyAvatar,
diff --git a/tasks/lessons.md b/tasks/lessons.md
deleted file mode 100644
index 1aeb5ad..0000000
--- a/tasks/lessons.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Lessons
-
-## 2026-04-10: spec の前提を検証するタイミング
-
-**状況:** `specs/2026-04-10-mobile-oauth-expo-design.md` の決定事項 #7 「`AtpAgent` を維持して `fetch: session.fetchHandler` を注入」を計画に落とし込んだが、Task 8 で `@atproto/oauth-client-expo@0.0.10` の実型定義を確認したところ、以下が判明:
-
-1. `OAuthSession.server` は `OAuthServerAgent` で、`.issuer` 直接取得はできない (`serverMetadata.issuer` が正しい)
-2. `OAuthSession.fetchHandler` は `(pathname: string, init?) => Promise<Response>` — 標準 `fetch(url, init)` とは別シグネチャ
-3. `AtpAgent` のコンストラクタは `AtpAgentOptions | CredentialSession` のみ受理し OAuthSession を直接注入できない
-4. `@atproto/api` 自身が `AtpAgent is deprecated, use Agent with CredentialSession instead` と JSDoc 警告
-5. 実際にブラウザ拡張版 (`src/lib/bskyClient.ts:120`) も `new Agent(oauthSession)` (基底 `Agent`) を使用
-
-**何が問題だったか:**
-spec を書いた時点で `OAuthSession` の実 API を確認せず「きっと `session.fetchHandler` が標準 fetch だろう」と推測で書いた。推測をそのまま計画に落とし込み、TDD テストまで書いてから食い違いが発覚するのはコスト高。
-
-**ルール:**
-外部パッケージの API に依存する設計判断 (型・プロパティ名・コンストラクタシグネチャ) は、**spec 段階で一度でいいから node_modules の `.d.ts` を読むか、パッケージのソースを開いて実在を確認する**。「多分こうだろう」で spec に書かない。特に `0.0.x` の若いパッケージは推測が当たらない前提で臨む。
-
-**How to apply:**
-次回パッケージ統合 spec を書くときは、「確定した設計判断」セクションに `✅ 型定義確認済み` のようなチェックマークを付けるか、もしくは spec 本体で該当プロパティ名を `.d.ts` から引用する。
-
-## 2026-04-10: gitignored ディレクトリへの spec 記述に注意
-
-**状況:** spec が `mobile/ios/SkyFollowerBridge/Info.plist` の手動編集をタスクとして指示していたが、`mobile/.gitignore` に `/ios` があり `mobile/ios/` はそもそも tracked されていない。`expo prebuild` で毎回再生成される扱い。
-
-**ルール:**
-タスク対象ファイルを spec に書く前に `git ls-files <path>` でトラック状況を確認する。gitignored ファイルへの変更は commit できず、spec のタスク構造が崩れる。tracked されていないなら、変更はソース (この場合 `mobile/app.json` の `scheme`) 側に寄せる。
-
-**How to apply:**
-- `mobile/ios/**`, `mobile/android/**`, `build/**`, `node_modules/**` など generated/ignored なパスに touch するタスクは spec 段階で赤信号
-- generated ファイルの状態を変えたいなら、生成元 (config, template) を変えるタスクに置き換える
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..ea78e68
--- /dev/null
+++ b/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"program":{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/scheduler/tracing.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/plasmo/templates/plasmo.d.ts","./node_modules/@plasmohq/messaging/dist/types-a2e5594b.d.ts","./node_modules/@plasmohq/messaging/dist/index.d.ts","./.plasmo/messaging.d.ts","./.plasmo/index.d.ts","./node_modules/@types/react-dom/client.d.ts","./.plasmo/static/devtools.tsx","./.plasmo/static/newtab.tsx","./node_modules/zod/lib/helpers/typealiases.d.ts","./node_modules/zod/lib/helpers/util.d.ts","./node_modules/zod/lib/zoderror.d.ts","./node_modules/zod/lib/locales/en.d.ts","./node_modules/zod/lib/errors.d.ts","./node_modules/zod/lib/helpers/parseutil.d.ts","./node_modules/zod/lib/helpers/enumutil.d.ts","./node_modules/zod/lib/helpers/errorutil.d.ts","./node_modules/zod/lib/helpers/partialutil.d.ts","./node_modules/zod/lib/types.d.ts","./node_modules/zod/lib/external.d.ts","./node_modules/zod/lib/index.d.ts","./node_modules/zod/index.d.ts","./node_modules/@atproto/lexicon/dist/types.d.ts","./node_modules/@atproto/lexicon/dist/lexicons.d.ts","./node_modules/multiformats/types/src/bases/interface.d.ts","./node_modules/multiformats/types/src/hashes/interface.d.ts","./node_modules/multiformats/types/src/cid.d.ts","./node_modules/@atproto/lexicon/dist/blob-refs.d.ts","./node_modules/@atproto/common-web/dist/check.d.ts","./node_modules/@atproto/common-web/dist/util.d.ts","./node_modules/@atproto/common-web/dist/arrays.d.ts","./node_modules/@atproto/common-web/dist/async.d.ts","./node_modules/@atproto/common-web/dist/tid.d.ts","./node_modules/@atproto/common-web/dist/ipld.d.ts","./node_modules/@atproto/common-web/dist/retry.d.ts","./node_modules/@atproto/lex-data/dist/cid.d.ts","./node_modules/@atproto/lex-data/dist/lex.d.ts","./node_modules/@atproto/lex-data/dist/blob.d.ts","./node_modules/@atproto/lex-data/dist/lex-equals.d.ts","./node_modules/@atproto/lex-data/dist/lex-error.d.ts","./node_modules/@atproto/lex-data/dist/object.d.ts","./node_modules/@atproto/lex-data/dist/uint8array-base64.d.ts","./node_modules/@atproto/lex-data/dist/uint8array.d.ts","./node_modules/@atproto/lex-data/dist/utf8.d.ts","./node_modules/@atproto/lex-data/dist/index.d.ts","./node_modules/@atproto/common-web/dist/types.d.ts","./node_modules/@atproto/common-web/dist/times.d.ts","./node_modules/@atproto/syntax/dist/did.d.ts","./node_modules/@atproto/syntax/dist/handle.d.ts","./node_modules/@atproto/syntax/dist/at-identifier.d.ts","./node_modules/@atproto/syntax/dist/nsid.d.ts","./node_modules/@atproto/syntax/dist/aturi_validation.d.ts","./node_modules/@atproto/syntax/dist/recordkey.d.ts","./node_modules/@atproto/syntax/dist/aturi.d.ts","./node_modules/@atproto/syntax/dist/datetime.d.ts","./node_modules/@atproto/syntax/dist/language.d.ts","./node_modules/@atproto/syntax/dist/tid.d.ts","./node_modules/@atproto/syntax/dist/uri.d.ts","./node_modules/@atproto/syntax/dist/index.d.ts","./node_modules/@atproto/common-web/dist/strings.d.ts","./node_modules/@atproto/common-web/dist/did-doc.d.ts","./node_modules/@atproto/common-web/dist/index.d.ts","./node_modules/@atproto/lexicon/dist/serialize.d.ts","./node_modules/@atproto/lexicon/dist/index.d.ts","./node_modules/@atproto/api/dist/client/util.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/label/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/richtext/facet.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/embed/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/embed/images.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/embed/video.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/embed/external.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/strongref.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/moderation/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/labeler/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/embed/recordwithmedia.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/embed/record.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/threadgate.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/postgate.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/actor/defs.d.ts","./node_modules/@plasmohq/storage/dist/index.d.ts","./node_modules/@plasmohq/storage/dist/hook.d.ts","./src/lib/constants.ts","./src/types.ts","./src/lib/bskyserviceworkerclient.ts","./src/lib/chromehelper.ts","./node_modules/consola/dist/core.d.ts","./node_modules/consola/dist/index.d.ts","./src/lib/utils.ts","./node_modules/@types/jaro-winkler/index.d.ts","./src/lib/bskyhelpers.ts","./src/lib/researchbskyusers.ts","./src/hooks/usebskyusermanager.ts","./node_modules/react-toastify/dist/components/closebutton.d.ts","./node_modules/react-toastify/dist/components/progressbar.d.ts","./node_modules/react-toastify/dist/components/toastcontainer.d.ts","./node_modules/react-toastify/dist/components/transitions.d.ts","./node_modules/react-toastify/dist/components/toast.d.ts","./node_modules/react-toastify/dist/components/icons.d.ts","./node_modules/react-toastify/dist/components/index.d.ts","./node_modules/react-toastify/dist/types.d.ts","./node_modules/react-toastify/dist/core/store.d.ts","./node_modules/react-toastify/dist/hooks/usetoastcontainer.d.ts","./node_modules/react-toastify/dist/hooks/usetoast.d.ts","./node_modules/react-toastify/dist/hooks/index.d.ts","./node_modules/react-toastify/dist/utils/propvalidator.d.ts","./node_modules/react-toastify/dist/utils/constant.d.ts","./node_modules/react-toastify/dist/utils/csstransition.d.ts","./node_modules/react-toastify/dist/utils/collapsetoast.d.ts","./node_modules/react-toastify/dist/utils/mapper.d.ts","./node_modules/react-toastify/dist/utils/index.d.ts","./node_modules/react-toastify/dist/core/toast.d.ts","./node_modules/react-toastify/dist/core/index.d.ts","./node_modules/react-toastify/dist/index.d.ts","./src/components/confirmdialog.tsx","./node_modules/ts-pattern/dist/internals/symbols.d.ts","./node_modules/ts-pattern/dist/types/helpers.d.ts","./node_modules/ts-pattern/dist/types/findselected.d.ts","./node_modules/ts-pattern/dist/types/pattern.d.ts","./node_modules/ts-pattern/dist/types/extractprecisevalue.d.ts","./node_modules/ts-pattern/dist/types/buildmany.d.ts","./node_modules/ts-pattern/dist/types/ismatching.d.ts","./node_modules/ts-pattern/dist/types/distributeunions.d.ts","./node_modules/ts-pattern/dist/types/deepexclude.d.ts","./node_modules/ts-pattern/dist/types/invertpattern.d.ts","./node_modules/ts-pattern/dist/patterns.d.ts","./node_modules/ts-pattern/dist/types/match.d.ts","./node_modules/ts-pattern/dist/match.d.ts","./node_modules/ts-pattern/dist/is-matching.d.ts","./node_modules/ts-pattern/dist/index.d.ts","./src/components/asyncbutton.tsx","./src/components/icons/blueskyiconsvg.tsx","./src/components/sharebutton.tsx","./src/components/sociallinks.tsx","./src/components/sidebar.tsx","./node_modules/@tanstack/virtual-core/dist/esm/utils.d.ts","./node_modules/@tanstack/virtual-core/dist/esm/index.d.ts","./node_modules/@tanstack/react-virtual/dist/esm/index.d.ts","./src/components/userinfo.tsx","./src/components/icons/avatarfallbacksvg.tsx","./src/components/userprofile.tsx","./src/components/detectedusersource.tsx","./src/components/actionbutton.tsx","./src/components/usercard.tsx","./src/components/detecteduserlistitem.tsx","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/framer-motion/dist/index.d.ts","./src/components/donationcard.tsx","./src/components/modal.tsx","./src/components/usercardwithoutactionbutton.tsx","./src/components/researchmodal.tsx","./src/options.tsx","./.plasmo/static/options.tsx","./package.json","./node_modules/destr/dist/index.d.ts","./src/hooks/useerrormessage.ts","./src/hooks/useauth.ts","./node_modules/@atproto/xrpc/dist/types.d.ts","./node_modules/@atproto/xrpc/dist/fetch-handler.d.ts","./node_modules/@atproto/xrpc/dist/xrpc-client.d.ts","./node_modules/@atproto/xrpc/dist/client.d.ts","./node_modules/@atproto/xrpc/dist/util.d.ts","./node_modules/@atproto/xrpc/dist/index.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/actor/getpreferences.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/actor/getprofile.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/actor/getprofiles.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/actor/getsuggestions.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/actor/profile.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/actor/putpreferences.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/actor/searchactors.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/actor/searchactorstypeahead.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/actor/status.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/ageassurance/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/ageassurance/begin.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/ageassurance/getconfig.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/ageassurance/getstate.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/bookmark/createbookmark.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/bookmark/deletebookmark.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/bookmark/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/bookmark/getbookmarks.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/contact/dismissmatch.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/contact/getmatches.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/contact/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/contact/getsyncstatus.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/contact/importcontacts.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/contact/removedata.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/contact/sendnotification.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/contact/startphoneverification.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/contact/verifyphone.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/draft/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/draft/createdraft.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/draft/deletedraft.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/draft/getdrafts.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/draft/updatedraft.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/describefeedgenerator.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/generator.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getactorfeeds.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getactorlikes.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getauthorfeed.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getfeed.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getfeedgenerator.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getfeedgenerators.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getfeedskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getlikes.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getlistfeed.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getpostthread.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getposts.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getquotes.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getrepostedby.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/getsuggestedfeeds.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/gettimeline.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/like.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/post.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/repost.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/searchposts.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/feed/sendinteractions.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/block.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/follow.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getactorstarterpacks.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getblocks.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getfollowers.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getfollows.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getknownfollowers.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getlist.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getlistblocks.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getlistmutes.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getlists.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getlistswithmembership.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getmutes.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getrelationships.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getstarterpack.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getstarterpacks.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getstarterpackswithmembership.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/getsuggestedfollowsbyactor.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/list.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/listblock.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/listitem.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/muteactor.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/muteactorlist.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/mutethread.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/searchstarterpacks.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/starterpack.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/unmuteactor.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/unmuteactorlist.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/unmutethread.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/graph/verification.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/labeler/getservices.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/labeler/service.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/declaration.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/getpreferences.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/getunreadcount.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/listactivitysubscriptions.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/listnotifications.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/putactivitysubscription.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/putpreferences.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/putpreferencesv2.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/registerpush.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/unregisterpush.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/notification/updateseen.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getageassurancestate.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getconfig.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getonboardingsuggestedstarterpacks.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getonboardingsuggestedstarterpacksskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getonboardingsuggestedusersskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getpopularfeedgenerators.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getpostthreadotherv2.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getpostthreadv2.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getsuggestedfeeds.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getsuggestedfeedsskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getsuggestedonboardingusers.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getsuggestedstarterpacks.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getsuggestedstarterpacksskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getsuggestedusers.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getsuggestedusersskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/getsuggestionsskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/gettaggedsuggestions.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/gettrendingtopics.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/gettrends.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/gettrendsskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/initageassurance.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/searchactorsskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/searchpostsskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/unspecced/searchstarterpacksskeleton.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/video/defs.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/video/getjobstatus.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/video/getuploadlimits.d.ts","./node_modules/@atproto/api/dist/client/types/app/bsky/video/uploadvideo.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/actor/declaration.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/actor/deleteaccount.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/actor/exportaccountdata.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/acceptconvo.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/actor/defs.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/defs.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/addreaction.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/deletemessageforself.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/getconvo.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/getconvoavailability.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/getconvoformembers.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/getlog.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/getmessages.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/leaveconvo.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/listconvos.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/muteconvo.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/removereaction.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/sendmessage.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/sendmessagebatch.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/unmuteconvo.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/updateallread.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/convo/updateread.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/moderation/getactormetadata.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/moderation/getmessagecontext.d.ts","./node_modules/@atproto/api/dist/client/types/chat/bsky/moderation/updateactoraccess.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/deleteaccount.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/disableaccountinvites.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/disableinvitecodes.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/enableaccountinvites.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/defs.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/defs.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/getaccountinfo.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/getaccountinfos.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/getinvitecodes.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/getsubjectstatus.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/searchaccounts.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/sendemail.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/updateaccountemail.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/updateaccounthandle.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/updateaccountpassword.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/updateaccountsigningkey.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/admin/updatesubjectstatus.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/identity/getrecommendeddidcredentials.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/identity/defs.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/identity/refreshidentity.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/identity/requestplcoperationsignature.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/identity/resolvedid.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/identity/resolvehandle.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/identity/resolveidentity.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/identity/signplcoperation.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/identity/submitplcoperation.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/identity/updatehandle.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/label/querylabels.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/lexicon/schema.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/lexicon/resolvelexicon.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/moderation/createreport.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/defs.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/applywrites.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/createrecord.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/deleterecord.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/describerepo.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/getrecord.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/importrepo.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/listmissingblobs.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/listrecords.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/putrecord.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/repo/uploadblob.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/activateaccount.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/checkaccountstatus.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/confirmemail.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/createaccount.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/createapppassword.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/createinvitecode.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/createinvitecodes.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/createsession.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/deactivateaccount.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/deleteaccount.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/deletesession.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/describeserver.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/getaccountinvitecodes.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/getserviceauth.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/getsession.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/listapppasswords.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/refreshsession.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/requestaccountdelete.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/requestemailconfirmation.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/requestemailupdate.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/requestpasswordreset.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/reservesigningkey.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/resetpassword.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/revokeapppassword.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/server/updateemail.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/getblob.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/getblocks.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/getcheckout.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/gethead.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/defs.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/gethoststatus.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/getlatestcommit.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/getrecord.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/getrepo.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/getrepostatus.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/listblobs.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/listhosts.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/listrepos.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/listreposbycollection.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/notifyofupdate.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/requestcrawl.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/temp/addreservedhandle.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/temp/checkhandleavailability.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/temp/checksignupqueue.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/temp/dereferencescope.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/temp/fetchlabels.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/temp/requestphoneverification.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/temp/revokeaccountcredentials.d.ts","./node_modules/@atproto/api/dist/client/types/com/germnetwork/declaration.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/communication/defs.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/communication/createtemplate.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/communication/deletetemplate.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/communication/listtemplates.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/communication/updatetemplate.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/hosting/getaccounthistory.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/cancelscheduledactions.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/defs.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/emitevent.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/getaccounttimeline.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/getevent.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/getrecord.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/getrecords.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/getrepo.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/getreporterstats.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/getrepos.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/getsubjects.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/listscheduledactions.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/queryevents.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/querystatuses.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/scheduleaction.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/moderation/searchrepos.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/safelink/defs.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/safelink/addrule.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/safelink/queryevents.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/safelink/queryrules.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/safelink/removerule.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/safelink/updaterule.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/server/getconfig.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/set/addvalues.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/set/deleteset.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/set/deletevalues.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/set/defs.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/set/getvalues.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/set/querysets.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/set/upsertset.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/setting/defs.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/setting/listoptions.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/setting/removeoptions.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/setting/upsertoption.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/signature/defs.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/signature/findcorrelation.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/signature/findrelatedaccounts.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/signature/searchaccounts.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/team/defs.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/team/addmember.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/team/deletemember.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/team/listmembers.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/team/updatemember.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/verification/defs.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/verification/grantverifications.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/verification/listverifications.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/verification/revokeverifications.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/label/subscribelabels.d.ts","./node_modules/@atproto/api/dist/client/types/com/atproto/sync/subscriberepos.d.ts","./node_modules/@atproto/api/dist/client/types/tools/ozone/report/defs.d.ts","./node_modules/@atproto/api/dist/client/index.d.ts","./node_modules/@atproto/api/dist/moderation/const/labels.d.ts","./node_modules/@atproto/api/dist/moderation/mutewords.d.ts","./node_modules/@atproto/api/dist/moderation/types.d.ts","./node_modules/@atproto/api/dist/types.d.ts","./node_modules/@atproto/api/dist/const.d.ts","./node_modules/@atproto/api/dist/util.d.ts","./node_modules/@atproto/api/dist/client/lexicons.d.ts","./node_modules/@atproto/api/dist/rich-text/unicode.d.ts","./node_modules/@atproto/api/dist/rich-text/rich-text.d.ts","./node_modules/@atproto/api/dist/rich-text/sanitization.d.ts","./node_modules/@atproto/api/dist/rich-text/util.d.ts","./node_modules/@atproto/api/dist/moderation/ui.d.ts","./node_modules/@atproto/api/dist/moderation/decision.d.ts","./node_modules/@atproto/api/dist/moderation/util.d.ts","./node_modules/@atproto/api/dist/moderation/index.d.ts","./node_modules/@atproto/api/dist/mocker.d.ts","./node_modules/@atproto/api/dist/age-assurance.d.ts","./node_modules/@atproto/api/dist/session-manager.d.ts","./node_modules/@atproto/api/dist/agent.d.ts","./node_modules/@atproto/api/dist/atp-agent.d.ts","./node_modules/@atproto/api/dist/bsky-agent.d.ts","./node_modules/@atproto/api/dist/index.d.ts","./src/hooks/usetypeaheadsearch.ts","./src/components/popup/typeaheaddropdown.tsx","./src/components/popup/authform.tsx","./src/components/popup/contact.tsx","./src/components/popup/errormessage.tsx","./src/components/popup/header.tsx","./src/components/popup/hint.tsx","./src/components/popup/searchform.tsx","./src/hooks/usesearch.ts","./src/popup.tsx","./.plasmo/static/popup.tsx","./.plasmo/static/sidepanel.tsx","./node_modules/@atproto/jwk/dist/jwk.d.ts","./node_modules/@atproto/jwk/dist/alg.d.ts","./node_modules/@atproto/jwk/dist/errors.d.ts","./node_modules/@atproto/jwk/dist/jwks.d.ts","./node_modules/@atproto/jwk/dist/jwt.d.ts","./node_modules/@atproto/jwk/dist/jwt-decode.d.ts","./node_modules/@atproto/jwk/dist/util.d.ts","./node_modules/@atproto/jwk/dist/jwt-verify.d.ts","./node_modules/@atproto/jwk/dist/key.d.ts","./node_modules/@atproto/jwk/dist/keyset.d.ts","./node_modules/@atproto/jwk/dist/index.d.ts","./node_modules/jose/dist/types/types.d.ts","./node_modules/jose/dist/types/jwe/compact/decrypt.d.ts","./node_modules/jose/dist/types/jwe/flattened/decrypt.d.ts","./node_modules/jose/dist/types/jwe/general/decrypt.d.ts","./node_modules/jose/dist/types/jwe/general/encrypt.d.ts","./node_modules/jose/dist/types/jws/compact/verify.d.ts","./node_modules/jose/dist/types/jws/flattened/verify.d.ts","./node_modules/jose/dist/types/jws/general/verify.d.ts","./node_modules/jose/dist/types/jwt/verify.d.ts","./node_modules/jose/dist/types/jwt/decrypt.d.ts","./node_modules/jose/dist/types/jwt/produce.d.ts","./node_modules/jose/dist/types/jwe/compact/encrypt.d.ts","./node_modules/jose/dist/types/jwe/flattened/encrypt.d.ts","./node_modules/jose/dist/types/jws/compact/sign.d.ts","./node_modules/jose/dist/types/jws/flattened/sign.d.ts","./node_modules/jose/dist/types/jws/general/sign.d.ts","./node_modules/jose/dist/types/jwt/sign.d.ts","./node_modules/jose/dist/types/jwt/encrypt.d.ts","./node_modules/jose/dist/types/jwk/thumbprint.d.ts","./node_modules/jose/dist/types/jwk/embedded.d.ts","./node_modules/jose/dist/types/jwks/local.d.ts","./node_modules/jose/dist/types/jwks/remote.d.ts","./node_modules/jose/dist/types/jwt/unsecured.d.ts","./node_modules/jose/dist/types/key/export.d.ts","./node_modules/jose/dist/types/key/import.d.ts","./node_modules/jose/dist/types/util/decode_protected_header.d.ts","./node_modules/jose/dist/types/util/decode_jwt.d.ts","./node_modules/jose/dist/types/util/errors.d.ts","./node_modules/jose/dist/types/key/generate_key_pair.d.ts","./node_modules/jose/dist/types/key/generate_secret.d.ts","./node_modules/jose/dist/types/util/base64url.d.ts","./node_modules/jose/dist/types/util/runtime.d.ts","./node_modules/jose/dist/types/index.d.ts","./node_modules/@atproto/jwk-jose/dist/jose-key.d.ts","./node_modules/@atproto/jwk-jose/dist/index.d.ts","./node_modules/@atproto/jwk-webcrypto/dist/webcrypto-key.d.ts","./node_modules/@atproto/jwk-webcrypto/dist/index.d.ts","./node_modules/@atproto/did/dist/did.d.ts","./node_modules/@atproto/did/dist/did-document.d.ts","./node_modules/@atproto/did/dist/utils.d.ts","./node_modules/@atproto/did/dist/atproto.d.ts","./node_modules/@atproto/did/dist/did-error.d.ts","./node_modules/@atproto/did/dist/methods/plc.d.ts","./node_modules/@atproto/did/dist/methods/web.d.ts","./node_modules/@atproto/did/dist/methods.d.ts","./node_modules/@atproto/did/dist/index.d.ts","./node_modules/@atproto-labs/simple-store/dist/util.d.ts","./node_modules/@atproto-labs/simple-store/dist/simple-store.d.ts","./node_modules/@atproto-labs/simple-store/dist/cached-getter.d.ts","./node_modules/@atproto-labs/simple-store/dist/index.d.ts","./node_modules/@atproto-labs/did-resolver/dist/did-method.d.ts","./node_modules/@atproto-labs/did-resolver/dist/did-resolver.d.ts","./node_modules/@atproto-labs/did-resolver/dist/did-cache.d.ts","./node_modules/@atproto-labs/did-resolver/dist/did-resolver-base.d.ts","./node_modules/@atproto-labs/fetch/dist/fetch-error.d.ts","./node_modules/@atproto-labs/fetch/dist/util.d.ts","./node_modules/@atproto-labs/fetch/dist/fetch.d.ts","./node_modules/@atproto-labs/fetch/dist/fetch-request.d.ts","./node_modules/@atproto-labs/pipe/dist/type.d.ts","./node_modules/@atproto-labs/pipe/dist/pipe.d.ts","./node_modules/@atproto-labs/pipe/dist/index.d.ts","./node_modules/@atproto-labs/fetch/dist/fetch-response.d.ts","./node_modules/@atproto-labs/fetch/dist/fetch-wrap.d.ts","./node_modules/@atproto-labs/fetch/dist/index.d.ts","./node_modules/@atproto-labs/did-resolver/dist/methods/plc.d.ts","./node_modules/@atproto-labs/did-resolver/dist/methods/web.d.ts","./node_modules/@atproto-labs/did-resolver/dist/util.d.ts","./node_modules/@atproto-labs/did-resolver/dist/did-resolver-common.d.ts","./node_modules/@atproto-labs/did-resolver/dist/create-did-resolver.d.ts","./node_modules/@atproto-labs/simple-store-memory/dist/index.d.ts","./node_modules/@atproto-labs/did-resolver/dist/did-cache-memory.d.ts","./node_modules/@atproto-labs/did-resolver/dist/methods.d.ts","./node_modules/@atproto-labs/did-resolver/dist/index.d.ts","./node_modules/@atproto-labs/handle-resolver/dist/handle-resolver-error.d.ts","./node_modules/@atproto-labs/handle-resolver/dist/types.d.ts","./node_modules/@atproto-labs/handle-resolver/dist/xrpc-handle-resolver.d.ts","./node_modules/@atproto-labs/handle-resolver/dist/internal-resolvers/dns-handle-resolver.d.ts","./node_modules/@atproto-labs/handle-resolver/dist/internal-resolvers/well-known-handler-resolver.d.ts","./node_modules/@atproto-labs/handle-resolver/dist/atproto-handle-resolver.d.ts","./node_modules/@atproto-labs/handle-resolver/dist/atproto-doh-handle-resolver.d.ts","./node_modules/@atproto-labs/handle-resolver/dist/cached-handle-resolver.d.ts","./node_modules/@atproto-labs/handle-resolver/dist/create-handle-resolver.d.ts","./node_modules/@atproto-labs/handle-resolver/dist/index.d.ts","./node_modules/@atproto/oauth-types/dist/constants.d.ts","./node_modules/@atproto/oauth-types/dist/uri.d.ts","./node_modules/@atproto/oauth-types/dist/util.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-scope.d.ts","./node_modules/@atproto/oauth-types/dist/atproto-oauth-scope.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-redirect-uri.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-client-id-loopback.d.ts","./node_modules/@atproto/oauth-types/dist/atproto-loopback-client-id.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-client-metadata.d.ts","./node_modules/@atproto/oauth-types/dist/atproto-loopback-client-metadata.d.ts","./node_modules/@atproto/oauth-types/dist/atproto-loopback-client-redirect-uris.d.ts","./node_modules/@atproto/oauth-types/dist/atproto-oauth-token-response.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-access-token.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-authorization-code-grant-token-request.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-authorization-details.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-authorization-request-jar.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-authorization-request-par.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-authorization-request-parameters.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-authorization-request-query.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-authorization-request-uri.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-authorization-response-error.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-authorization-server-metadata.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-client-credentials-grant-token-request.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-client-credentials.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-client-id-discoverable.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-client-id.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-endpoint-auth-method.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-endpoint-name.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-grant-type.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-token-type.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-introspection-response.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-issuer-identifier.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-par-response.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-password-grant-token-request.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-prompt-mode.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-protected-resource-metadata.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-refresh-token-grant-token-request.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-refresh-token.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-request-uri.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-response-mode.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-response-type.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-token-identification.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-token-request.d.ts","./node_modules/@atproto/oauth-types/dist/oauth-token-response.d.ts","./node_modules/@atproto/oauth-types/dist/oidc-authorization-error-response.d.ts","./node_modules/@atproto/oauth-types/dist/oidc-claims-parameter.d.ts","./node_modules/@atproto/oauth-types/dist/oidc-claims-properties.d.ts","./node_modules/@atproto/oauth-types/dist/oidc-entity-type.d.ts","./node_modules/@atproto/oauth-types/dist/oidc-userinfo.d.ts","./node_modules/@atproto/oauth-types/dist/index.d.ts","./node_modules/@atproto/oauth-client/dist/util.d.ts","./node_modules/@atproto/oauth-client/dist/runtime-implementation.d.ts","./node_modules/@atproto/oauth-client/dist/lock.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-authorization-server-metadata-resolver.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-callback-error.d.ts","./node_modules/@atproto-labs/identity-resolver/dist/constants.d.ts","./node_modules/@atproto-labs/identity-resolver/dist/identity-resolver.d.ts","./node_modules/@atproto-labs/identity-resolver/dist/atproto-identity-resolver.d.ts","./node_modules/@atproto-labs/identity-resolver/dist/create-identity-resolver.d.ts","./node_modules/@atproto-labs/identity-resolver/dist/identity-resolver-error.d.ts","./node_modules/@atproto-labs/identity-resolver/dist/util.d.ts","./node_modules/@atproto-labs/identity-resolver/dist/index.d.ts","./node_modules/@atproto/oauth-client/dist/identity-resolver.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-protected-resource-metadata-resolver.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-resolver.d.ts","./node_modules/@atproto/oauth-client/dist/runtime.d.ts","./node_modules/@atproto/oauth-client/dist/types.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-client-auth.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-server-agent.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-server-factory.d.ts","./node_modules/@atproto/oauth-client/dist/errors/auth-method-unsatisfiable-error.d.ts","./node_modules/@atproto/oauth-client/dist/errors/token-invalid-error.d.ts","./node_modules/@atproto/oauth-client/dist/errors/token-refresh-error.d.ts","./node_modules/@atproto/oauth-client/dist/errors/token-revoked-error.d.ts","./node_modules/@atproto/oauth-client/dist/session-getter.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-session.d.ts","./node_modules/@atproto/oauth-client/dist/state-store.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-client.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-resolver-error.d.ts","./node_modules/@atproto/oauth-client/dist/oauth-response-error.d.ts","./node_modules/@atproto/oauth-client/dist/index.d.ts","./src/lib/bskyoauthclient.ts","./src/lib/bskyclient.ts","./src/background/messages/addusertolist.ts","./src/background/messages/block.ts","./src/background/messages/createlist.ts","./src/background/messages/follow.ts","./src/lib/getimagesimilarityscore.ts","./src/background/messages/getimagesimilarityscore.ts","./src/background/messages/getmyprofile.ts","./src/background/messages/getprofile.ts","./src/background/messages/login.ts","./src/background/messages/logout.ts","./src/background/messages/openoptionpage.ts","./src/background/messages/searchuser.ts","./src/background/messages/unblock.ts","./src/background/messages/unfollow.ts","./.plasmo/static/background/messaging.ts","./.plasmo/static/background/index.ts","./.plasmo/static/common/csui-container-react.tsx","./.plasmo/static/common/csui-container-vanilla.tsx","./.plasmo/static/common/csui.ts","./.plasmo/static/common/react.ts","./.plasmo/static/common/vue.ts","./node_modules/plasmo/dist/type.d.ts","./src/components/alerterror.tsx","./src/components/usercardskeleton.tsx","./src/components/loadingcards.tsx","./src/components/servicealert.tsx","./src/lib/searchbskyusers.ts","./src/services/facebookservice.ts","./src/services/instagramservice.ts","./src/services/threadsservice.ts","./src/lib/domhelpers.ts","./src/services/tiktokservice.ts","./src/services/xservice.ts","./src/hooks/useretrievebskyusers.ts","./src/contents/app.tsx","./.plasmo/static/contents/app.tsx","./node_modules/use-debounce/dist/usedebouncedcallback.d.ts","./no…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.