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
6 changes: 6 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { handleSDKDeepLink, handleSchemeDeepLink, handleUniversalLinkDeepLink }
import { isOnboardingComplete } from '@/lib/settings';
import { fetchPushMessage } from '@/lib/push';
import { saveMessage, hasMessageWithPushId, findMessageByPushId } from '@/lib/storage';
import { isBlocked } from '@/lib/moderation';
import { ThemeProvider, useTheme } from '@/lib/theme';
import type { Message, RevealStyle } from '@/lib/types';

Expand Down Expand Up @@ -168,6 +169,11 @@ export default function RootLayout() {

const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const senderPhone = msg.senderPhone || (data.senderPhone as string | undefined);

// Drop messages from blocked senders (server also drops them; this covers
// messages already in flight when the block was created)
if (senderPhone && (await isBlocked(senderPhone))) return null;

const message: Message = {
id,
pushMessageId,
Expand Down
11 changes: 11 additions & 0 deletions app/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { getSettings } from '@/lib/settings';
import { isPayloadTooLarge } from '@/lib/deeplink';
import { createShareLink, isConnected, trackEvent } from '@/lib/sdk';
import { sendPushMessage } from '@/lib/push';
import { containsObjectionableContent } from '@/lib/content-filter';
import { REVEAL_STYLES } from '@/lib/reveal-styles';
import { useTheme } from '@/lib/theme';
import type { RevealStyle, Message } from '@/lib/types';
Expand Down Expand Up @@ -70,6 +71,16 @@ export default function CreateScreen() {
async function handleShare() {
if (!canShare || sharing) return;

// Content filter must run client-side: messages are encrypted end-to-end,
// so this is the only place objectionable content can be caught pre-send.
if (containsObjectionableContent(content) || containsObjectionableContent(senderName)) {
Alert.alert(
'Message Blocked',
"This message appears to contain offensive language, which isn't allowed on Fliq'd. Please edit it and try again.",
);
return;
}

const payload = {
content: content.trim(),
revealStyle,
Expand Down
46 changes: 42 additions & 4 deletions app/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Platform,
ScrollView,
Alert,
Linking,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
Expand All @@ -25,14 +26,18 @@ import { verifyOtp } from '@/lib/auth';
import { useTheme } from '@/lib/theme';

const PUSH_STEP = 2;
const PROFILE_STEP = 4;
const TOTAL_STEPS = 5;
const TERMS_STEP = 4;
const PROFILE_STEP = 5;
const TOTAL_STEPS = 6;

const TERMS_URL = 'https://fliq.linkforty.com/terms';
const PRIVACY_URL = 'https://fliq.linkforty.com/privacy';

const STEPS = [
{
emoji: '🤫',
title: 'Your secrets. Delivered.',
body: 'Send secret messages that only the recipient can reveal. No accounts, no trace, pure privacy.',
body: 'Send secret messages that only the recipient can reveal. No feeds, no followers — just private messages between friends.',
},
{
emoji: '🫰',
Expand All @@ -49,6 +54,11 @@ const STEPS = [
title: 'Ephemeral by design',
body: "Messages vanish after reading. Your secrets are encrypted end-to-end — not even Fliq'd can read them. Delete messages anytime, or let the app auto-delete.",
},
{
emoji: '🤝',
title: 'Keep it kind',
body: "Fliq'd has zero tolerance for objectionable content or abusive behavior. You can report any message you receive and block any sender — reports are reviewed within 24 hours, and violators are removed.",
},
{
emoji: '👋',
title: 'Set up your profile',
Expand All @@ -72,10 +82,12 @@ export default function OnboardingScreen() {
const [otpCode, setOtpCode] = useState('');
const [otpVerifying, setOtpVerifying] = useState(false);
const [direction, setDirection] = useState<'forward' | 'back'>('forward');
const [termsAcceptedAt, setTermsAcceptedAt] = useState<string | null>(null);
const nameInputRef = useRef<TextInput>(null);

const isLastStep = step === PROFILE_STEP;
const isPushStep = step === PUSH_STEP;
const isTermsStep = step === TERMS_STEP;
const current = STEPS[step];

async function handleEnablePush() {
Expand Down Expand Up @@ -162,6 +174,7 @@ export default function OnboardingScreen() {
userName: name.trim(),
phoneNumber: phone,
onboardingComplete: true,
termsAcceptedAt: termsAcceptedAt || new Date().toISOString(),
});

if (pushToken) {
Expand All @@ -175,6 +188,9 @@ export default function OnboardingScreen() {
trackEvent('onboarding_completed', { userName: name.trim(), hasPush: pushEnabled });
router.replace('/');
} else {
if (isTermsStep && !termsAcceptedAt) {
setTermsAcceptedAt(new Date().toISOString());
}
setDirection('forward');
setStep((s) => s + 1);
}
Expand Down Expand Up @@ -267,6 +283,28 @@ export default function OnboardingScreen() {
</View>
)}

{/* Terms of Service links on the agreement step */}
{isTermsStep && (
<View className="w-full mt-8 items-center">
<View className="flex-row items-center">
<Pressable onPress={() => Linking.openURL(TERMS_URL)} className="active:opacity-70">
<Text className="text-sm font-semibold underline" style={{ color: colors.accent }}>
Terms of Service
</Text>
</Pressable>
<Text className="text-sm mx-2" style={{ color: colors.textTertiary }}>·</Text>
<Pressable onPress={() => Linking.openURL(PRIVACY_URL)} className="active:opacity-70">
<Text className="text-sm font-semibold underline" style={{ color: colors.accent }}>
Privacy Policy
</Text>
</Pressable>
</View>
<Text className="text-xs text-center mt-3 px-4" style={{ color: colors.textTertiary }}>
By tapping "I Agree" you accept the Terms of Service.
</Text>
</View>
)}

{/* Profile inputs on last step */}
{isLastStep && (
<View className="w-full mt-8">
Expand Down Expand Up @@ -468,7 +506,7 @@ export default function OnboardingScreen() {
className="font-bold text-base"
style={{ color: colors.accentText }}
>
{isLastStep ? 'Get Started' : 'Next'}
{isLastStep ? 'Get Started' : isTermsStep ? 'I Agree' : 'Next'}
</Text>
</Pressable>
)}
Expand Down
85 changes: 84 additions & 1 deletion app/reveal/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react';
import { View, Text, Pressable } from 'react-native';
import { View, Text, Pressable, Alert } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { getMessages, markAsRead, deleteMessage } from '@/lib/storage';
Expand All @@ -12,6 +12,8 @@ import { FlickReveal } from '@/components/FlickReveal';
import { TypewriterReveal } from '@/components/TypewriterReveal';
import { FlipReveal } from '@/components/FlipReveal';
import { useTheme } from '@/lib/theme';
import { reportMessage, blockSender, REPORT_REASONS } from '@/lib/moderation';
import type { ReportReason } from '@/lib/moderation';
import type { Message, RevealStyle } from '@/lib/types';

export default function RevealScreen() {
Expand Down Expand Up @@ -40,6 +42,69 @@ export default function RevealScreen() {
setError('Message not found');
}

function handleReport() {
if (!message) return;

const submit = async (reason: ReportReason) => {
const result = await reportMessage({
senderPhone: message.senderPhone,
senderName: message.senderName,
messageId: message.pushMessageId,
content: message.content,
reason,
});
if ('error' in result) {
Alert.alert('Report Failed', result.error);
return;
}
if (message.senderPhone) {
Alert.alert(
'Report Submitted',
'Thank you. We review all reports within 24 hours and remove violators. Would you also like to block this sender?',
[
{ text: 'Not Now', style: 'cancel' },
{ text: 'Block Sender', style: 'destructive', onPress: handleBlock },
],
);
} else {
Alert.alert('Report Submitted', 'Thank you. We review all reports within 24 hours and remove violators.');
}
};

Alert.alert('Report This Message', "What's wrong with it?", [
...REPORT_REASONS.map(({ value, label }) => ({
text: label,
onPress: () => void submit(value),
})),
{ text: 'Cancel', style: 'cancel' as const },
]);
}

function handleBlock() {
if (!message?.senderPhone) return;
const phone = message.senderPhone;

Alert.alert(
`Block ${message.senderName}?`,
"They won't be able to send you secrets anymore. You can unblock them in Settings.",
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Block',
style: 'destructive',
onPress: async () => {
const result = await blockSender(phone);
if ('error' in result) {
Alert.alert('Block Failed', result.error);
} else {
Alert.alert('Blocked', 'This sender can no longer send you secrets.');
}
},
},
],
);
}

const handleRevealed = useCallback(async () => {
setRevealed(true);
if (message) {
Expand Down Expand Up @@ -189,6 +254,24 @@ export default function RevealScreen() {
Go back
</Text>
</Pressable>

{/* Safety actions — required for received UGC */}
{message.direction === 'received' && (
<View className="flex-row justify-center mt-1">
<Pressable onPress={handleReport} className="py-2 px-3 active:opacity-70">
<Text className="text-xs" style={{ color: '#ef4444' }}>
Report
</Text>
</Pressable>
{message.senderPhone ? (
<Pressable onPress={handleBlock} className="py-2 px-3 active:opacity-70">
<Text className="text-xs" style={{ color: '#ef4444' }}>
Block Sender
</Text>
</Pressable>
) : null}
</View>
)}
</View>
)}
</View>
Expand Down
88 changes: 88 additions & 0 deletions app/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Alert,
KeyboardAvoidingView,
Platform,
Linking,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
Expand All @@ -17,10 +18,12 @@ import { getSettings, saveSettings, clearSettings } from '@/lib/settings';
import { initializeSDK, isConnected, resetSDK } from '@/lib/sdk';
import { clearMessages, clearRecentRecipients } from '@/lib/storage';
import { registerForPushNotifications, registerDevice } from '@/lib/push';
import { syncBlocklist, unblockSender } from '@/lib/moderation';
import { useTheme } from '@/lib/theme';
import type { ThemePreference } from '@/lib/settings';

const DEFAULT_BASE_URL = 'https://api.linkforty.com';
const SUPPORT_EMAIL = 'support@linkforty.com';

const THEME_OPTIONS: { value: ThemePreference; label: string }[] = [
{ value: 'dark', label: 'Dark' },
Expand All @@ -44,11 +47,30 @@ export default function SettingsScreen() {
const [showAdvanced, setShowAdvanced] = useState(false);
const [showLegal, setShowLegal] = useState(false);
const [showData, setShowData] = useState(false);
const [blockedPhones, setBlockedPhones] = useState<string[]>([]);

useEffect(() => {
loadSettings();
syncBlocklist().then(setBlockedPhones);
}, []);

function handleUnblock(phone: string) {
Alert.alert('Unblock this number?', `${phone} will be able to send you secrets again.`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Unblock',
onPress: async () => {
const result = await unblockSender(phone);
if ('error' in result) {
Alert.alert('Unblock Failed', result.error);
} else {
setBlockedPhones((phones) => phones.filter((p) => p !== phone));
}
},
},
]);
}

async function loadSettings() {
const settings = await getSettings();
if (settings.apiKey) setApiKey(settings.apiKey);
Expand Down Expand Up @@ -315,6 +337,72 @@ export default function SettingsScreen() {
</View>
</View>

{/* Safety */}
<View className="pt-6 mt-6" style={{ borderTopWidth: 1, borderTopColor: colors.sectionBorder }}>
<Text
className="text-xs font-bold uppercase tracking-widest mb-3"
style={{ color: colors.textSecondary }}
>
Safety
</Text>

{/* Blocked numbers */}
<View
className="p-4 rounded-xl"
style={{ ...colors.bgCard, borderWidth: 1, borderColor: colors.cardBorder }}
>
<Text className="font-semibold mb-1" style={{ color: colors.textPrimary }}>
Blocked Numbers
</Text>
{blockedPhones.length === 0 ? (
<Text className="text-xs" style={{ color: colors.textTertiary }}>
No blocked numbers. You can block a sender after revealing their message.
</Text>
) : (
blockedPhones.map((phone) => (
<View
key={phone}
className="flex-row items-center justify-between py-2.5"
style={{ borderTopWidth: 1, borderTopColor: colors.cardBorder }}
>
<Text className="text-base" style={{ color: colors.textPrimary }}>
+{phone}
</Text>
<Pressable onPress={() => handleUnblock(phone)} className="py-1 px-2 active:opacity-70">
<Text className="text-sm font-semibold" style={{ color: colors.accent }}>
Unblock
</Text>
</Pressable>
</View>
))
)}
</View>

{/* Report a problem */}
<Pressable
onPress={() =>
Linking.openURL(
`mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent("Fliq'd: Report inappropriate activity")}`,
)
}
className="flex-row items-center justify-between p-4 rounded-xl mt-3"
style={{ ...colors.bgCard, borderWidth: 1, borderColor: colors.cardBorder }}
>
<View className="flex-1 mr-4">
<Text className="font-semibold" style={{ color: colors.textPrimary }}>
Report a Problem
</Text>
<Text className="text-xs mt-0.5" style={{ color: colors.textTertiary }}>
Contact us about inappropriate activity: {SUPPORT_EMAIL}
</Text>
</View>
<Text className="text-lg" style={{ color: colors.textTertiary }}>&rsaquo;</Text>
</Pressable>
<Text className="text-xs mt-2 mb-0" style={{ color: colors.textTertiary }}>
Fliq'd has zero tolerance for objectionable content or abusive users. Reports are reviewed within 24 hours.
</Text>
</View>

{/* Advanced Settings Toggle */}
<View className="mt-10 pt-6" style={{ borderTopWidth: 1, borderTopColor: colors.sectionBorder }}>
<Pressable
Expand Down
Loading