diff --git a/README.md b/README.md index 1aa8078a..d856a63c 100644 --- a/README.md +++ b/README.md @@ -40,20 +40,29 @@ Building the Android version requires Java 17. On Ubuntu you can run `sudo apt install openjdk-17-jdk`. ``` -cd js/app - # iOS -npx expo run:ios -d "Your iPhone" +yarn run app ios -d "Your iPhone" # Android -npx expo run:android +yarn run app android # Web http://localhost:38081 -npx expo start +yarn run app start ``` You can also boot the other platforms directly from the bundler once it starts. +Some app development may require actual HTTPS to test the OAuth flow. To make +that happen, you can set the `AQ_APP_SCHEME` environment variable to the scheme +you want to use; it must match reverse of the URL of a publicly accessible +server with an HTTPS cert. So, if you're exposing your app on +aquareum.example.com, you could run the app with + +``` +export AQ_APP_SCHEME=com.example.aquareum +yarn run app ios +``` + **Desktop Development** ``` diff --git a/js/app/app.config.ts b/js/app/app.config.ts index f3d5bfc6..151c8c49 100644 --- a/js/app/app.config.ts +++ b/js/app/app.config.ts @@ -54,6 +54,7 @@ export default function () { const pkg = require("./package.json"); const name = isProd ? "Aquareum" : "Devquarium"; const bundle = isProd ? "tv.aquareum" : "tv.aquareum.dev"; + const scheme = process.env["AQ_APP_SCHEME"] ?? bundle; return { expo: { name: name, @@ -63,7 +64,7 @@ export default function () { runtimeVersion: pkg.runtimeVersion, orientation: "default", icon: "./assets/images/icon.png", - scheme: "myapp", + scheme: scheme, userInterfaceStyle: "automatic", splash: { image: "./assets/images/splash.png", diff --git a/js/app/components/login/login.tsx b/js/app/components/login/login.tsx index 7bf25956..ae41ea02 100644 --- a/js/app/components/login/login.tsx +++ b/js/app/components/login/login.tsx @@ -1,14 +1,27 @@ -import { Button, View, Text } from "tamagui"; -import { useAppDispatch, useAppSelector } from "store/hooks"; import { - login, logout, selectUserProfile, + selectPDS, + setPDS, + selectLogin, + login, } from "features/bluesky/blueskySlice"; +import { useState } from "react"; +import { Keyboard } from "react-native"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import { Button, H3, Input, Sheet, Text, View, Form, Spinner } from "tamagui"; export default function Login() { const dispatch = useAppDispatch(); const userProfile = useAppSelector(selectUserProfile); + const pds = useAppSelector(selectPDS); + const loginState = useAppSelector(selectLogin); + console.log("pds", pds); + const [open, setOpen] = useState(false); + const onOpenChange = (open: boolean) => { + setOpen(open); + Keyboard.dismiss(); + }; if (userProfile) { return ( @@ -20,15 +33,109 @@ export default function Login() { } return ( - + + {/* {error} */} + ); } + +export function ChangePDS({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const pds = useAppSelector(selectPDS); + const dispatch = useAppDispatch(); + const [newURL, setNewURL] = useState(""); + return ( + + + + + +
{ + await dispatch(setPDS(newURL)); + onOpenChange(false); + }} + > + {/* + + +
+
+
+ ); +} diff --git a/js/app/components/provider/provider.shared.tsx b/js/app/components/provider/provider.shared.tsx index 46524f13..356c2c90 100644 --- a/js/app/components/provider/provider.shared.tsx +++ b/js/app/components/provider/provider.shared.tsx @@ -11,8 +11,8 @@ import { PortalProvider, TamaguiProvider } from "tamagui"; import config from "tamagui.config"; import { CurrentToast } from "./CurrentToast"; import { Provider as ReduxProvider } from "react-redux"; -import { store } from "store/store"; import BlueskyProvider from "features/bluesky/blueskyProvider"; +import { store } from "store/store"; export default function Provider({ children, linking, @@ -24,8 +24,8 @@ export default function Provider({ - - + + - - + + diff --git a/js/app/components/settings/settings.tsx b/js/app/components/settings/settings.tsx index 87f933f3..8719be31 100644 --- a/js/app/components/settings/settings.tsx +++ b/js/app/components/settings/settings.tsx @@ -1,9 +1,9 @@ -import { Button, Form, H3, Input, View, XStack } from "tamagui"; -import { Updates } from "./updates"; +import { setURL } from "features/aquareum/aquareumSlice"; import useAquareumNode from "hooks/useAquareumNode"; import { useState } from "react"; import { useAppDispatch } from "store/hooks"; -import { setURL } from "features/aquareum/aquareumSlice"; +import { Button, Form, H3, Input, View, XStack } from "tamagui"; +import { Updates } from "./updates"; export function Settings() { const dispatch = useAppDispatch(); @@ -33,6 +33,9 @@ export function Settings() { placeholder={url} onChangeText={(t) => setNewUrl(t)} onSubmitEditing={onSubmit} + textContentType="URL" + autoCapitalize="none" + autoCorrect={false} /> diff --git a/js/app/features/aquareum/aquareumProvider.tsx b/js/app/features/aquareum/aquareumProvider.tsx index f92627b1..9d4f74de 100644 --- a/js/app/features/aquareum/aquareumProvider.tsx +++ b/js/app/features/aquareum/aquareumProvider.tsx @@ -1,6 +1,8 @@ -import { createContext } from "react"; -import { DEFAULT_URL, selectAquareum } from "./aquareumSlice"; -import { useAppSelector } from "store/hooks"; +import { createContext, useEffect } from "react"; +import { DEFAULT_URL, initialize, selectAquareum } from "./aquareumSlice"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import Loading from "components/loading/loading"; +import { View, Text } from "tamagui"; export const AquareumContext = createContext({ url: DEFAULT_URL, @@ -12,6 +14,20 @@ export default function AquareumProvider({ children: React.ReactNode; }): React.ReactElement { const aquareum = useAppSelector(selectAquareum); + const dispatch = useAppDispatch(); + useEffect(() => { + if (!aquareum.initialized) { + dispatch(initialize()); + } + }, [aquareum.initialized]); + if (!aquareum.initialized) { + return ( + + AquareumProvider loading... + + + ); + } return ( {children} diff --git a/js/app/features/aquareum/aquareumSlice.tsx b/js/app/features/aquareum/aquareumSlice.tsx index 1a3cd221..68dfa689 100644 --- a/js/app/features/aquareum/aquareumSlice.tsx +++ b/js/app/features/aquareum/aquareumSlice.tsx @@ -2,6 +2,7 @@ import { createAppSlice } from "../../hooks/createSlice"; import { isWeb } from "tamagui"; import { SignTypedDataFn } from "hooks/useWallet.shared"; import schema from "generated/eip712-schema.json"; +import Storage from "../../storage"; let DEFAULT_URL = process.env.EXPO_PUBLIC_AQUAREUM_URL as string; if (isWeb && process.env.EXPO_PUBLIC_WEB_TRY_LOCAL === "true") { @@ -22,18 +23,49 @@ export interface Identity { export interface AquareumState { url: string; identity: Identity | null; + initialized: boolean; } const initialState: AquareumState = { url: DEFAULT_URL, identity: null, + initialized: false, }; export const aquareumSlice = createAppSlice({ name: "aquareum", initialState, reducers: (create) => ({ + initialize: create.asyncThunk( + async (_, { getState }) => { + let url = await Storage.getItem("aquareumUrl"); + if (!url) { + url = DEFAULT_URL; + } + return url; + }, + { + pending: (state) => { + // state.status = "loading"; + }, + fulfilled: (state, action) => { + const url = action.payload; + return { + ...state, + url, + initialized: true, + }; + }, + rejected: (_, { error }) => { + // state.status = "failed"; + }, + }, + ), + setURL: create.reducer((state, action: { payload: string }) => { + Storage.setItem("aquareumUrl", action.payload).catch((err) => { + console.error("setURL error", err); + }); return { ...state, url: action.payload, @@ -142,5 +174,6 @@ export const aquareumSlice = createAppSlice({ }); // Action creators are generated for each case reducer function. -export const { getIdentity, putIdentity, setURL } = aquareumSlice.actions; +export const { getIdentity, putIdentity, setURL, initialize } = + aquareumSlice.actions; export const { selectAquareum } = aquareumSlice.selectors; diff --git a/js/app/features/bluesky/blueskyProvider.tsx b/js/app/features/bluesky/blueskyProvider.tsx index b34303ae..d461b50e 100644 --- a/js/app/features/bluesky/blueskyProvider.tsx +++ b/js/app/features/bluesky/blueskyProvider.tsx @@ -1,13 +1,15 @@ -import { useEffect } from "react"; +import { useURL } from "expo-linking"; +import { putIdentity } from "features/aquareum/aquareumSlice"; +import useWallet from "hooks/useWallet"; +import { useEffect, useState } from "react"; import { useAppDispatch, useAppSelector } from "store/hooks"; import { getProfile, loadOAuthClient, + oauthCallback, selectOAuthSession, selectUserProfile, } from "./blueskySlice"; -import { putIdentity } from "features/aquareum/aquareumSlice"; -import useWallet from "hooks/useWallet"; export default function BlueskyProvider({ children, @@ -21,9 +23,24 @@ export default function BlueskyProvider({ const oauthSession = useAppSelector(selectOAuthSession); const userProfile = useAppSelector(selectUserProfile); const wallet = useWallet(); + + const [lastLink, setLastLink] = useState(null); + const url = useURL(); + + useEffect(() => { + if (url !== lastLink && url) { + setLastLink(url); + if (url.includes("?")) { + const params = new URLSearchParams(url.split("?")[1]); + if (params.has("code") && params.has("state") && params.has("iss")) { + dispatch(oauthCallback(url)); + } + } + } + }, [url]); + useEffect(() => { if (oauthSession && !userProfile) { - console.log("oauthSession", oauthSession); dispatch(getProfile(oauthSession.did)); } if (oauthSession && userProfile && wallet.address) { @@ -36,7 +53,6 @@ export default function BlueskyProvider({ }), ); } - console.log(wallet); }, [oauthSession, userProfile, wallet.address]); return <>{children}; } diff --git a/js/app/features/bluesky/blueskySlice.native.tsx b/js/app/features/bluesky/blueskySlice.native.tsx deleted file mode 100644 index 5e38c761..00000000 --- a/js/app/features/bluesky/blueskySlice.native.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { OAuthSession } from "@atproto/oauth-client"; -import { createAppSlice } from "../../hooks/createSlice"; -import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; -import { Agent } from "@atproto/api"; - -export interface BlueskyState { - status: "start" | "loggedIn" | "loggedOut"; - oauthState: null | string; - oauthSession: null | OAuthSession; - pdsAgent: null | Agent; - profiles: { [key: string]: ProfileViewDetailed }; - client: null; -} - -const initialState: BlueskyState = { - status: "start", - oauthState: null, - oauthSession: null, - pdsAgent: null, - profiles: {}, - client: null, -}; - -export const blueskySlice = createAppSlice({ - name: "bluesky", - initialState, - reducers: (create) => ({ - loadOAuthClient: create.asyncThunk(async (_, { getState }) => {}, { - pending: (state) => { - // state.status = "loading"; - }, - fulfilled: (state, action) => {}, - rejected: (_, { error }) => {}, - }), - - login: create.asyncThunk(async (pds: string, thunkAPI) => {}, { - pending: (state) => { - // state.status = "loading"; - }, - fulfilled: (state, action) => { - return state; - }, - rejected: (state, action) => { - console.error("login rejected", action.error); - return { - ...state, - profiles: {}, - }; - // state.status = "failed"; - }, - }), - - logout: create.asyncThunk(async (_, thunkAPI) => {}, { - pending: (state) => { - // state.status = "loading"; - }, - fulfilled: (state, action) => {}, - rejected: (state) => {}, - }), - - getProfile: create.asyncThunk(async (actor: string, thunkAPI) => {}, { - pending: (state) => { - // state.status = "loading"; - }, - fulfilled: (state, action) => {}, - rejected: (state, action) => {}, - }), - - golivePost: create.asyncThunk( - async ( - { - nodeUrl, - signingKey, - text, - }: { nodeUrl: string; signingKey: string; text: string }, - thunkAPI, - ) => {}, - { - pending: (state) => { - console.log("golivePost pending"); - }, - fulfilled: (state, action) => { - console.log("golivePost fulfilled", action.payload); - }, - rejected: (state, action) => { - console.error("getProfile rejected", action.error); - // state.status = "failed"; - }, - }, - ), - }), - - // You can define your selectors here. These selectors receive the slice - // state as their first argument. - selectors: { - selectOAuthSession: (bluesky) => bluesky.oauthSession, - selectProfiles: (bluesky) => bluesky.profiles, - selectUserProfile: (bluesky) => { - const did = bluesky.oauthSession?.did; - if (!did) return null; - return bluesky.profiles[did]; - }, - }, -}); - -// Action creators are generated for each case reducer function. -export const { loadOAuthClient, login, getProfile, logout, golivePost } = - blueskySlice.actions; - -// Selectors returned by `slice.selectors` take the root state as their first argument. -export const { selectOAuthSession, selectProfiles, selectUserProfile } = - blueskySlice.selectors; diff --git a/js/app/features/bluesky/blueskySlice.tsx b/js/app/features/bluesky/blueskySlice.tsx index c23d8f60..6af1ae29 100644 --- a/js/app/features/bluesky/blueskySlice.tsx +++ b/js/app/features/bluesky/blueskySlice.tsx @@ -1,10 +1,11 @@ -import { OAuthSession } from "@atproto/oauth-client"; -import { createAppSlice } from "../../hooks/createSlice"; import { Agent } from "@atproto/api"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { OAuthSession } from "@atproto/oauth-client"; import { AquareumState } from "features/aquareum/aquareumSlice"; +import { openLoginLink } from "features/platform/platformSlice"; +import Storage from "storage"; +import { createAppSlice } from "../../hooks/createSlice"; import createOAuthClient, { AquareumOAuthClient } from "./oauthClient"; - export interface BlueskyState { status: "start" | "loggedIn" | "loggedOut"; oauthState: null | string; @@ -12,6 +13,15 @@ export interface BlueskyState { pdsAgent: null | Agent; profiles: { [key: string]: ProfileViewDetailed }; client: null | AquareumOAuthClient; + login: { + loading: boolean; + error: null | string; + }; + pds: { + url: string; + loading: boolean; + error: null | string; + }; } const initialState: BlueskyState = { @@ -21,6 +31,15 @@ const initialState: BlueskyState = { pdsAgent: null, profiles: {}, client: null, + login: { + loading: false, + error: null, + }, + pds: { + url: "bsky.social", + loading: false, + error: null, + }, }; export const blueskySlice = createAppSlice({ @@ -31,7 +50,7 @@ export const blueskySlice = createAppSlice({ async (_, { getState }) => { const { aquareum } = getState() as { aquareum: AquareumState }; const client = await createOAuthClient(aquareum.url); - const initResult = await client.init(); + let initResult = await client.init(); return { client, initResult }; }, { @@ -42,14 +61,14 @@ export const blueskySlice = createAppSlice({ const { client, initResult } = action.payload; console.log("loadOAuthClient fulfilled", action.payload); // sometimes the codes don't get removed from the url properly? so we do so here. - const u = new URL(document.location.href); - u.search = ""; - window.history.replaceState(null, "", u.toString()); + // const u = new URL(document.location.href); + // u.search = ""; + // window.history.replaceState(null, "", u.toString()); if (initResult && "session" in initResult) { return { ...state, client: client, - oauthSession: initResult.session, + oauthSession: initResult.session as any, pdsAgent: new Agent(initResult.session), }; } @@ -70,30 +89,45 @@ export const blueskySlice = createAppSlice({ let { bluesky } = thunkAPI.getState() as { bluesky: BlueskyState; }; - if (!bluesky.client) { - await thunkAPI.dispatch(loadOAuthClient()); - } + await thunkAPI.dispatch(loadOAuthClient()); ({ bluesky } = thunkAPI.getState() as { bluesky: BlueskyState; }); if (!bluesky.client) { throw new Error("No client"); } - return await bluesky.client.authorize(pds); + const u = await bluesky.client.authorize(pds); + thunkAPI.dispatch(openLoginLink(u.toString())); + // cheeky 500ms delay so you don't see the text flash back + await new Promise((resolve) => setTimeout(resolve, 500)); }, { pending: (state) => { - // state.status = "loading"; + return { + ...state, + login: { + loading: true, + error: null, + }, + }; }, fulfilled: (state, action) => { - document.location.href = action.payload.toString(); - return state; + // document.location.href = action.payload.toString(); + return { + ...state, + login: { + loading: false, + error: null, + }, + }; }, rejected: (state, action) => { - console.error("login rejected", action.error); return { ...state, - profiles: {}, + login: { + loading: false, + error: action.error?.message ?? null, + }, }; // state.status = "failed"; }, @@ -102,6 +136,7 @@ export const blueskySlice = createAppSlice({ logout: create.asyncThunk( async (_, thunkAPI) => { + await Storage.removeItem("did"); const { bluesky } = thunkAPI.getState() as { bluesky: BlueskyState; }; @@ -160,6 +195,55 @@ export const blueskySlice = createAppSlice({ }, ), + oauthCallback: create.asyncThunk( + async (url: string, thunkAPI) => { + if (!url.includes("?")) { + throw new Error("No query params"); + } + const params = new URLSearchParams(url.split("?")[1]); + if (!(params.has("code") && params.has("state") && params.has("iss"))) { + throw new Error("Missing params, got: " + url); + } + const { bluesky } = thunkAPI.getState() as { + bluesky: BlueskyState; + }; + if (!bluesky.client) { + throw new Error("No client"); + } + try { + const ret = await bluesky.client.callback(params); + await Storage.setItem("did", ret.session.did); + + return ret.session as any; + } catch (e) { + let message = e.message; + while (e.cause) { + message = `${message}: ${e.cause.message}`; + e = e.cause; + } + console.error("oauthCallback error", message); + throw e; + } + }, + + { + pending: (state) => { + // state.status = "loading"; + }, + fulfilled: (state, action) => { + console.log("oauthCallback fulfilled", action.payload); + return { + ...state, + oauthSession: action.payload as any, + pdsAgent: new Agent(action.payload) as any, + }; + }, + rejected: (state, action) => { + console.error("oauthCallback rejected", action.error); + }, + }, + ), + golivePost: create.asyncThunk( async ( { @@ -229,12 +313,53 @@ export const blueskySlice = createAppSlice({ }, }, ), + setPDS: create.asyncThunk( + async (pds: string, thunkAPI) => { + await Storage.setItem("pdsURL", pds); + return pds; + }, + { + pending: (state, action) => { + return { + ...state, + pds: { + ...state.pds, + loading: true, + }, + }; + }, + fulfilled: (state, action) => { + // document.location.href = action.payload.toString(); + console.log("setPDS fulfilled", action.payload); + return { + ...state, + pds: { + ...state.pds, + loading: false, + url: action.payload, + }, + }; + }, + rejected: (state, action) => { + return { + ...state, + pds: { + ...state.pds, + loading: false, + error: action.error?.message ?? null, + }, + }; + }, + }, + ), }), // You can define your selectors here. These selectors receive the slice // state as their first argument. selectors: { selectOAuthSession: (bluesky) => bluesky.oauthSession, + selectPDS: (bluesky) => bluesky.pds, + selectLogin: (bluesky) => bluesky.login, selectProfiles: (bluesky) => bluesky.profiles, selectUserProfile: (bluesky) => { const did = bluesky.oauthSession?.did; @@ -245,9 +370,21 @@ export const blueskySlice = createAppSlice({ }); // Action creators are generated for each case reducer function. -export const { loadOAuthClient, login, getProfile, logout, golivePost } = - blueskySlice.actions; +export const { + loadOAuthClient, + login, + getProfile, + logout, + golivePost, + oauthCallback, + setPDS, +} = blueskySlice.actions; // Selectors returned by `slice.selectors` take the root state as their first argument. -export const { selectOAuthSession, selectProfiles, selectUserProfile } = - blueskySlice.selectors; +export const { + selectOAuthSession, + selectProfiles, + selectUserProfile, + selectPDS, + selectLogin, +} = blueskySlice.selectors; diff --git a/js/app/features/bluesky/oauthClient.tsx b/js/app/features/bluesky/oauthClient.tsx index 41d9d6af..b6991469 100644 --- a/js/app/features/bluesky/oauthClient.tsx +++ b/js/app/features/bluesky/oauthClient.tsx @@ -1,10 +1,13 @@ import { - BrowserOAuthClient, - OAuthClientMetadata, -} from "@atproto/oauth-client-browser"; + ClientMetadata, + clientMetadataSchema, + ReactNativeOAuthClient, +} from "@aquareum/atproto-oauth-client-react-native"; +import Constants from "expo-constants"; +import { Platform } from "react-native"; export type AquareumOAuthClient = Omit< - BrowserOAuthClient, + ReactNativeOAuthClient, "keyset" | "serverFactory" | "jwks" >; @@ -14,31 +17,52 @@ export default async function createOAuthClient( if (!aquareumUrl) { throw new Error("aquareumUrl is required"); } - let meta: OAuthClientMetadata; + let meta: ClientMetadata; if ( aquareumUrl.startsWith("http://localhost") || aquareumUrl.startsWith("http://127.0.0.1") ) { - const u = new URL(document.location.href); - - // loopback client that doesn't require interaction with the server + const isWeb = Platform.OS === "web"; + const u = new URL(aquareumUrl); + let hostname = u.hostname; + if (hostname == "localhost") { + hostname = "127.0.0.1"; + } + let redirect = `${u.protocol}//${hostname}`; + if (u.port !== "") { + redirect = `${redirect}:${u.port}`; + } + if (isWeb) { + redirect = `${redirect}/login`; + } else { + const scheme = Constants.expoConfig?.scheme; + if (!scheme) { + throw new Error("unable to resolve scheme for oauth redirect"); + } + redirect = `${redirect}/app-return/${scheme}`; + } + const queryParams = new URLSearchParams(); + queryParams.set("scope", "atproto transition:generic"); + queryParams.set("redirect_uri", redirect); meta = { - client_id: "http://localhost?scope=atproto%20transition:generic", - redirect_uris: [`${u.protocol}//${u.host}`], + client_id: `http://localhost?${queryParams.toString()}`, + redirect_uris: [redirect as any], scope: "atproto transition:generic", token_endpoint_auth_method: "none", - // jwks_uri: "https://my-app.example/jwks.json", client_name: "Loopback client", response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], + // > There is a special exception for the localhost development workflow [ ... ] + // > These clients use web URLs, but have application_type set to native in the generated client metadata. application_type: "native", dpop_bound_access_tokens: true, }; } else { - const res = await fetch(`${aquareumUrl}/api/atproto-oauth`); + const res = await fetch(`${aquareumUrl}/api/atproto-oauth/${Platform.OS}`); meta = await res.json(); } - return new BrowserOAuthClient({ + clientMetadataSchema.parse(meta); + return new ReactNativeOAuthClient({ handleResolver: "https://bsky.social", // backend instances should use a DNS based resolver responseMode: "query", // or "fragment" (frontend only) or "form_post" (backend only) diff --git a/js/app/features/platform/README.md b/js/app/features/platform/README.md new file mode 100644 index 00000000..8bf441eb --- /dev/null +++ b/js/app/features/platform/README.md @@ -0,0 +1,3 @@ +# Platform + +Redux reducer for handling platform-specific actions like opening a login link. diff --git a/js/app/features/platform/platformSlice.native.tsx b/js/app/features/platform/platformSlice.native.tsx new file mode 100644 index 00000000..bba702d6 --- /dev/null +++ b/js/app/features/platform/platformSlice.native.tsx @@ -0,0 +1,41 @@ +import { openAuthSessionAsync } from "expo-web-browser"; +import { createAppSlice } from "../../hooks/createSlice"; +import { oauthCallback } from "features/bluesky/blueskySlice"; + +export interface PlatformState { + status: "idle" | "loading" | "failed"; +} + +const initialState: PlatformState = { + status: "idle", +}; + +export const platformSlice = createAppSlice({ + name: "platform", + initialState, + reducers: (create) => ({ + openLoginLink: create.asyncThunk( + async (url: string, thunkAPI) => { + console.log("openLoginLink", url); + const res = await openAuthSessionAsync(url); + if (res.type === "success") { + thunkAPI.dispatch(oauthCallback(res.url)); + } + }, + { + pending: (state) => { + state.status = "loading"; + }, + fulfilled: (state) => { + state.status = "idle"; + }, + rejected: (state, { error }) => { + state.status = "failed"; + console.error(error); + }, + }, + ), + }), +}); + +export const { openLoginLink } = platformSlice.actions; diff --git a/js/app/features/platform/platformSlice.tsx b/js/app/features/platform/platformSlice.tsx new file mode 100644 index 00000000..697940cd --- /dev/null +++ b/js/app/features/platform/platformSlice.tsx @@ -0,0 +1,34 @@ +import { createAppSlice } from "../../hooks/createSlice"; + +export interface PlatformState { + status: "idle" | "loading" | "failed"; +} + +const initialState: PlatformState = { + status: "idle", +}; + +export const platformSlice = createAppSlice({ + name: "platform", + initialState, + reducers: (create) => ({ + openLoginLink: create.asyncThunk( + async (url: string) => { + window.location.href = url; + }, + { + pending: (state) => { + state.status = "loading"; + }, + fulfilled: (state) => { + state.status = "idle"; + }, + rejected: (state) => { + state.status = "failed"; + }, + }, + ), + }), +}); + +export const { openLoginLink } = platformSlice.actions; diff --git a/js/app/metro.config.js b/js/app/metro.config.js index 0aa7f02f..bb53ea89 100644 --- a/js/app/metro.config.js +++ b/js/app/metro.config.js @@ -4,19 +4,36 @@ */ const { getDefaultConfig } = require("expo/metro-config"); -const config = getDefaultConfig(__dirname, { +let config = getDefaultConfig(__dirname, { // [Web-only]: Enables CSS support in Metro. isCSSEnabled: true, }); // Enable Tamagui and add nice web support with optimizing compiler + CSS extraction const { withTamagui } = require("@tamagui/metro-plugin"); -module.exports = withTamagui(config, { +config = withTamagui(config, { components: ["tamagui"], config: "./tamagui.config.ts", outputCSS: "./tamagui-web.css", }); +const nativeOverrides = { + crypto: "react-native-quick-crypto", + stream: "readable-stream", +}; + +config.resolver.resolveRequest = (context, moduleName, platform) => { + if (platform !== "web") { + for (const [key, value] of Object.entries(nativeOverrides)) { + if (moduleName === key) { + return context.resolveRequest(context, value, platform); + } + } + } + // otherwise chain to the standard Metro resolver. + return context.resolveRequest(context, moduleName, platform); +}; + config.resolver.sourceExts.push("mjs"); config.resolver.assetExts.push("md"); diff --git a/js/app/package.json b/js/app/package.json index d3c8ef81..78bac702 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -25,6 +25,7 @@ "preset": "jest-expo" }, "dependencies": { + "@aquareum/atproto-oauth-client-react-native": "^0.0.1", "@atproto-labs/pipe": "^0.1.0", "@atproto/jwk-jose": "^0.1.2", "@atproto/oauth-client": "^0.3.1", @@ -41,9 +42,11 @@ "@tamagui/lucide-icons": "^1.116.12", "@tamagui/toast": "^1.116.12", "@tanstack/react-query": "^5.59.19", + "abortcontroller-polyfill": "^1.7.6", "babel-preset-expo": "~12.0.0", "burnt": "^0.12.2", "chrono-node": "^2.7.7", + "event-target-shim": "^6.0.2", "expo": "~52.0.8", "expo-build-properties": "~0.13.1", "expo-dev-client": "~5.0.3", @@ -57,13 +60,15 @@ "expo-system-ui": "~4.0.3", "expo-updates": "~0.26.7", "expo-video": "~2.0.1", - "expo-web-browser": "~14.0.1", + "expo-web-browser": "^14.0.1", "hls.js": "^1.5.17", + "jose": "^5.9.6", "react": "18.3.1", "react-dom": "18.3.1", "react-native": "0.76.2", "react-native-gesture-handler": "~2.20.2", "react-native-markdown-display": "^7.0.2", + "react-native-quick-crypto": "^0.7.7", "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.1.0", diff --git a/js/app/src/entrypoint.tsx b/js/app/src/entrypoint.tsx index 92fbed65..73e7634c 100644 --- a/js/app/src/entrypoint.tsx +++ b/js/app/src/entrypoint.tsx @@ -2,7 +2,8 @@ // hot module reloading works properly on web. import "@expo/metro-runtime"; -import { registerRootComponent } from "expo"; +import "./polyfills"; import Router from "./router"; +import { registerRootComponent } from "expo"; registerRootComponent(Router); diff --git a/js/app/src/polyfills.native.tsx b/js/app/src/polyfills.native.tsx new file mode 100644 index 00000000..ad28c65b --- /dev/null +++ b/js/app/src/polyfills.native.tsx @@ -0,0 +1 @@ +// there used to be stuff here diff --git a/js/app/features/bluesky/oauthClient.native.tsx b/js/app/src/polyfills.tsx similarity index 100% rename from js/app/features/bluesky/oauthClient.native.tsx rename to js/app/src/polyfills.tsx diff --git a/js/app/src/router.tsx b/js/app/src/router.tsx index 6be8698b..691bcd66 100644 --- a/js/app/src/router.tsx +++ b/js/app/src/router.tsx @@ -13,23 +13,29 @@ import { LogIn, Menu, Settings as SettingsIcon, - Video, User, + Video, } from "@tamagui/lucide-icons"; import { Provider, Settings } from "components"; +import AQLink from "components/aqlink"; +import Login from "components/login/login"; import StreamList from "components/stream-list/stream-list"; +import { selectUserProfile } from "features/bluesky/blueskySlice"; import usePlatform from "hooks/usePlatform"; import { useEffect } from "react"; -import { ImageBackground, ImageSourcePropType, Pressable } from "react-native"; +import { + ImageBackground, + ImageSourcePropType, + Pressable, + StatusBar, +} from "react-native"; +import { useAppSelector } from "store/hooks"; import { useTheme, View } from "tamagui"; +import AppReturnScreen from "./screens/app-return"; +import GoLiveScreen from "./screens/golive"; import MultiScreen from "./screens/multi"; import StreamScreen from "./screens/stream"; import SupportScreen from "./screens/support"; -import GoLiveScreen from "./screens/golive"; -import Login from "components/login/login"; -import { selectUserProfile } from "features/bluesky/blueskySlice"; -import { useAppSelector } from "store/hooks"; -import AQLink from "components/aqlink"; function HomeScreen() { return ( @@ -58,6 +64,7 @@ const linking: LinkingOptions = { Settings: "settings", GoLive: "golive", Login: "login", + AppReturn: "app-return/:scheme", }, }, }; @@ -94,6 +101,8 @@ const AvatarButton = () => { return ( , - headerRight: () => , - drawerActiveTintColor: theme.accentColor.val, - headerStyle: {}, - }} - > - , - headerTitle: "Aquareum", - headerShown: isWeb, - title: "Aquareum", + <> + + , + headerRight: () => , + drawerActiveTintColor: theme.accentColor.val, }} - listeners={{ - drawerItemPress: (e) => { - e.preventDefault(); - navigation.dispatch( - CommonActions.reset({ - index: 0, - routes: [ - { - name: "Home", - state: { - routes: [{ name: "StreamList" }], + > + , + headerTitle: "Aquareum", + headerShown: isWeb, + title: "Aquareum", + }} + listeners={{ + drawerItemPress: (e) => { + e.preventDefault(); + navigation.dispatch( + CommonActions.reset({ + index: 0, + routes: [ + { + name: "Home", + state: { + routes: [{ name: "StreamList" }], + }, }, - }, - ], - }), - ); - }, - }} - /> - }} - /> - null, - drawerItemStyle: { display: "none" }, - }} - /> - null, - drawerItemStyle: { display: "none" }, - }} - /> - }} - /> - {isElectron && ( + ], + }), + ); + }, + }} + /> + }} + /> + null, + drawerItemStyle: { display: "none" }, + }} + /> + null, + drawerItemStyle: { display: "none" }, + }} + /> + null, + drawerItemStyle: { display: "none" }, + }} + /> + {isElectron && ( +