Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion app/(drawer)/(tabs)/lifestyle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ const SERVICE_MENU_ITEMS: MenuItemData[] = [
navigateTo: 'userMessages',
params: { pubkey: SUPPORT_PUBKEY },
},
{
id: 'routstr',
icon: 'mdi:robot',
label: 'AI Chat',
navigateTo: 'routstr',
},
// Empty placeholders to maintain grid layout
{ id: 'empty1', empty: true },
{ id: 'empty2', empty: true },
Expand Down Expand Up @@ -126,7 +132,9 @@ const ServicesSection = () => {
</Text>
<View style={styles.gridContainer}>
{SERVICE_MENU_ITEMS.filter((item) =>
['giftcards', 'vpn', 'esims', 'donate'].includes(item.id) ? settings?.experimental : true
['giftcards', 'vpn', 'esims', 'donate', 'routstr'].includes(item.id)
? settings?.experimental
: true
).map((item) => (
<MenuItem
key={item.id}
Expand Down
143 changes: 143 additions & 0 deletions app/routstr.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet, FlatList, Alert } from 'react-native';
import Container from 'components/layout/Container';
import { View } from 'components/common/View';
import { Text } from 'components/common/Text';
import TextInput from 'components/common/TextInput';
import { Button } from 'components/common/Button';
import Icon from 'assets/icons';
import { useSelector } from 'react-redux';
import { memoizedGetTheme } from 'helper/redux/settings';
import { useTypedNavigation } from 'helper/navigation';
import { memoizedGetBalance } from 'helper/redux/cashu';
import { useRoutstr } from 'helper/redux/routstr';
import { SheetManager } from 'react-native-actions-sheet';
import { greys } from 'helper/colors';

const MIN_BALANCE = 1000;

export default function RoutstrChat() {
const theme = useSelector(memoizedGetTheme);
const styles = createStyles(theme);
const balance = useSelector(memoizedGetBalance('sat'));
const navigation = useTypedNavigation();
const {
token,
balance: routstrBalance,
currentSession,
sessions,
createSession,
addMessage,
setCurrentSession,
lastToken,
} = useRoutstr();
const [input, setInput] = useState('');

useEffect(() => {
if (!currentSession) {
const id = Date.now().toString();
createSession(id);
}
}, []);

const openSessionSheet = () => {
SheetManager.show('routstr-session', {
payload: { sessions, current: currentSession?.id },
});
};

const handleNewSession = () => {
const id = Date.now().toString();
createSession(id);
};

const handleSend = async () => {
if (!token) {
Alert.alert('No token found');
return;
}
if (routstrBalance < MIN_BALANCE) {
Alert.alert('Balance too low, please topup');
return;
}
if (!currentSession) return;
addMessage(currentSession.id, { role: 'user', content: input });
setInput('');
try {
const res = await fetch('https://api.routstr.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4',
messages: [...currentSession.messages, { role: 'user', content: input }],
}),
});
const data = await res.json();
const reply = data?.choices?.[0]?.message;
if (reply) {
addMessage(currentSession.id, reply);
}
} catch (e) {
Alert.alert('Error', 'Failed to fetch response');
}
};

const renderItem = ({ item }) => (
<View style={styles.messageItem}>
<Text>
{item.role === 'user' ? 'You' : 'AI'}: {item.content}
</Text>
</View>
);

return (
<Container>
<View style={styles.header}>
<Button
variant="secondary"
icon={<Icon name="mdi:menu" />}
onPress={openSessionSheet}
style={styles.iconButton}
/>
<Button
variant="secondary"
icon={<Icon name="mdi:plus" />}
onPress={handleNewSession}
style={styles.iconButton}
/>
</View>
<Text style={styles.balance} onPress={() => navigation.navigate('routstr/balance')}>
Balance: {routstrBalance} sats
</Text>
<FlatList
data={currentSession?.messages || []}
renderItem={renderItem}
keyExtractor={(_, i) => i.toString()}
style={styles.list}
/>
<View style={styles.inputRow}>
<TextInput style={styles.input} value={input} onChangeText={setInput} />
<Button variant="primary" onPress={handleSend} text="Send" style={{ marginLeft: 8 }} />
</View>
</Container>
);
}

const createStyles = (theme: string) =>
StyleSheet.create({
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16,
},
iconButton: { width: 48, height: 48 },
balance: { marginVertical: 8, color: greys(theme)[0] },
list: { flex: 1 },
inputRow: { flexDirection: 'row', marginBottom: 16 },
input: { flex: 1, padding: 8 },
messageItem: { marginVertical: 4 },
});
89 changes: 89 additions & 0 deletions app/routstr/balance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { StyleSheet, Alert } from 'react-native';
import Container from 'components/layout/Container';
import { View } from 'components/common/View';
import { Text } from 'components/common/Text';
import TextInput from 'components/common/TextInput';
import { Button } from 'components/common/Button';
import { useSelector } from 'react-redux';
import { memoizedGetTheme } from 'helper/redux/settings';
import { greys } from 'helper/colors';
import { useRoutstr } from 'helper/redux/routstr';
import { sendEcash } from 'helper/cashu/pay';

export default function RoutstrBalance() {
const theme = useSelector(memoizedGetTheme);
const styles = createStyles(theme);
const { token, balance, setBalance, setLastToken } = useRoutstr();
const [amount, setAmount] = useState('');

const fetchInfo = async () => {
if (!token) return;
const res = await fetch('https://api.routstr.com/v1/wallet/', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (data?.balance !== undefined) {
setBalance(data.balance);
}
};

const handleTopup = async () => {
try {
const tx = await sendEcash({ amount: Number(amount), unit: 'sat' });
setLastToken(tx.token);
await fetch(
`https://api.routstr.com/v1/wallet/topup?cashu_token=${encodeURIComponent(tx.token)}`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}
);
await fetchInfo();
Alert.alert('Topup successful');
} catch (e) {
Alert.alert('Error', 'Topup failed');
}
};

const handleRefund = async () => {
try {
const res = await fetch('https://api.routstr.com/v1/wallet/refund', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (data?.cashu_token) {
setLastToken(data.cashu_token);
setBalance(data.new_balance || 0);
Alert.alert('Refund token received');
}
} catch (e) {
Alert.alert('Error', 'Refund failed');
}
};

return (
<Container>
<Text style={styles.balance}>Balance: {balance} sats</Text>
<View style={styles.row}>
<TextInput
keyboardType="numeric"
value={amount}
onChangeText={setAmount}
style={styles.input}
placeholder="Amount"
/>
<Button text="Topup" onPress={handleTopup} style={{ marginLeft: 8 }} />
</View>
<Button text="Refund" onPress={handleRefund} style={{ marginTop: 16 }} />
</Container>
);
}

const createStyles = (theme: string) =>
StyleSheet.create({
balance: { marginVertical: 16, color: greys(theme)[0] },
row: { flexDirection: 'row', alignItems: 'center' },
input: { flex: 1, padding: 8 },
});
2 changes: 2 additions & 0 deletions components/layout/sheets/registerSheets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { default as registerCreditCard } from 'components/layout/sheets/creditCa
import { default as registerEmojiPicker } from 'components/layout/sheets/emoji-picker';
import { default as registerEmail } from 'components/layout/sheets/email';
import { default as registerVideo } from 'components/layout/sheets/video';
import { default as registerRoutstrSession } from 'components/layout/sheets/routstrSession';

export function registerAllSheets({ context }: { context: 'global' | 'modal' }) {
registerExample({ context });
Expand All @@ -26,4 +27,5 @@ export function registerAllSheets({ context }: { context: 'global' | 'modal' })
registerEmojiPicker({ context });
registerEmail({ context });
registerVideo({ context });
registerRoutstrSession({ context });
}
21 changes: 21 additions & 0 deletions components/layout/sheets/routstrSession/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import ActionSheet, { registerSheet } from 'react-native-actions-sheet';
import { sheetName, routes } from './routes';
import { useSelector } from 'react-redux';
import { memoizedGetTheme } from 'helper/redux/settings';
import { greys } from 'helper/colors';

function RoutstrSessionSheet() {
const theme = useSelector(memoizedGetTheme);
return (
<ActionSheet
enableRouterBackNavigation={true}
routes={routes}
initialRoute="route-a"
containerStyle={{ backgroundColor: greys(theme)[800] }}
/>
);
}

export default ({ context }: { context: 'global' | 'modal' }) =>
registerSheet(sheetName, RoutstrSessionSheet, context);
25 changes: 25 additions & 0 deletions components/layout/sheets/routstrSession/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Route, SheetDefinition, RouteDefinition } from 'react-native-actions-sheet';
import RouteA from './routeA';

export const sheetName = 'routstr-session';

export const routes: Route[] = [
{
name: 'route-a',
component: RouteA,
},
];

declare module 'react-native-actions-sheet' {
interface Sheets {
[sheetName]: SheetDefinition<{
routes: {
'route-a': RouteDefinition;
};
payload: {
sessions: { id: string }[];
current?: string | null;
};
}>;
}
}
53 changes: 53 additions & 0 deletions components/layout/sheets/routstrSession/routes/routeA.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';
import { View, TouchableOpacity } from 'react-native';
import { Text } from 'components/common/Text';
import { useSelector } from 'react-redux';
import { memoizedGetTheme } from 'helper/redux/settings';
import { greys } from 'helper/colors';
import { RouteScreenProps, useSheetPayload } from 'react-native-actions-sheet';
import { useRoutstr } from 'helper/redux/routstr';

const RouteA = ({ router }: RouteScreenProps<'routstr-session', 'route-a'>) => {
const theme = useSelector(memoizedGetTheme);
const styles = createStyles(theme);
const payload = useSheetPayload('routstr-session');
const { sessions, setCurrentSession, createSession } = useRoutstr();

return (
<View style={styles.container}>
{sessions.map((s) => (
<TouchableOpacity
key={s.id}
style={styles.item}
onPress={() => {
setCurrentSession(s.id);
router?.goBack();
}}>
<Text style={styles.text}>{s.id}</Text>
</TouchableOpacity>
))}
<TouchableOpacity
style={styles.item}
onPress={() => {
const id = Date.now().toString();
createSession(id);
router?.goBack();
}}>
<Text style={styles.text}>New Session</Text>
</TouchableOpacity>
</View>
);
};

const createStyles = (theme: string) => ({
container: { padding: 16 },
item: {
padding: 12,
backgroundColor: greys(theme)[700],
borderRadius: 8,
marginBottom: 8,
},
text: { color: greys(theme)[0] },
});

export default RouteA;
6 changes: 6 additions & 0 deletions helper/navigation/screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ export const MODAL_SCREENS: ModalConfig[] = [
presentation: 'card',
},
},
{
name: 'routstr',
options: {
presentation: 'card',
},
},
{
name: 'esim',
options: {
Expand Down
6 changes: 6 additions & 0 deletions helper/redux/routstr/actionTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const SET_ROUTSTR_TOKEN = 'SET_ROUTSTR_TOKEN';
export const SET_ROUTSTR_BALANCE = 'SET_ROUTSTR_BALANCE';
export const ADD_ROUTSTR_SESSION = 'ADD_ROUTSTR_SESSION';
export const ADD_ROUTSTR_MESSAGE = 'ADD_ROUTSTR_MESSAGE';
export const SET_CURRENT_SESSION = 'SET_CURRENT_SESSION';
export const SET_LAST_TOKEN = 'SET_LAST_TOKEN';
Loading