Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 4 additions & 2 deletions src/__tests__/components/TweetComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ jest.mock('expo-image-picker', () => ({
},
}));

jest.mock('expo-file-system', () => ({
getInfoAsync: jest.fn().mockResolvedValue({ exists: true, size: 1024 * 1024 }), // 1MB mock
}));

jest.mock('expo-video', () => {
const addListener = jest.fn(() => ({ remove: jest.fn() }));
const React = jest.requireActual('react');
Expand Down Expand Up @@ -280,7 +284,6 @@ describe('TweetComposer', () => {
mediaTypes: ['images', 'videos'],
allowsMultipleSelection: true,
selectionLimit: 4,
allowsEditing: true,
quality: 1,
videoMaxDuration: 140,
exif: false,
Expand Down Expand Up @@ -558,7 +561,6 @@ describe('TweetComposer', () => {
expect(mockLaunchCamera).toHaveBeenCalledWith({
mediaTypes: ['images'],
quality: 1,
allowsEditing: true,
exif: false,
});
});
Expand Down
93 changes: 78 additions & 15 deletions src/components/ui/TweetComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {

import { Ionicons } from '@expo/vector-icons';
import { useQueryClient } from '@tanstack/react-query';
import * as FileSystem from 'expo-file-system/legacy';
import * as ImagePicker from 'expo-image-picker';
import { replaceTriggerValues } from 'react-native-controlled-mentions';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
Expand Down Expand Up @@ -67,6 +68,26 @@ type TweetComposerProps = {
const CHARACTER_LIMIT = 280;
const MAX_CHARACTER_LIMIT = Math.ceil(CHARACTER_LIMIT / 100) * 100;

const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
const MAX_VIDEO_SIZE = 10 * 1024 * 1024;

const checkFileSize = async (
uri: string,
isVideo: boolean
): Promise<{ valid: boolean; sizeMB?: number }> => {
try {
const info = await FileSystem.getInfoAsync(uri);
if (!info.exists || info.size === undefined || info.size === null) {
return { valid: true };
}
const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
const sizeMB = info.size / (1024 * 1024);
return { valid: info.size <= maxSize, sizeMB };
} catch {
return { valid: true };
}
};

export default function TweetComposer({
visible,
onClose,
Expand Down Expand Up @@ -131,36 +152,59 @@ export default function TweetComposer({
mediaTypes: ['images', 'videos'],
allowsMultipleSelection: true,
selectionLimit: 4 - selectedAssets.length,
allowsEditing: true,
quality: 1,
videoMaxDuration: 140,
exif: false,
...(Platform.OS === 'ios' && { legacy: true }),
});

if (!result.canceled) {
const newAssets: Asset[] = result.assets.map((asset) => ({
id: asset.fileName || asset.uri,
uri: asset.uri,
fileName: asset.fileName ?? undefined,
mediaType: asset.duration ? 'video' : 'photo',
duration: asset.duration ? asset.duration / 1000 : undefined,
}));
const oversizedFiles: string[] = [];
const validAssets: Asset[] = [];

for (const asset of result.assets) {
const isVideo = !!asset.duration;
const sizeCheck = await checkFileSize(asset.uri, isVideo);

if (!sizeCheck.valid) {
const maxMB = isVideo ? 10 : 5;
oversizedFiles.push(
`${asset.fileName || 'File'} (${sizeCheck.sizeMB?.toFixed(1)}MB > ${maxMB}MB)`
);
} else {
validAssets.push({
id: asset.fileName || asset.uri,
uri: asset.uri,
fileName: asset.fileName ?? undefined,
mediaType: isVideo ? 'video' : 'photo',
duration: asset.duration ? asset.duration / 1000 : undefined,
});
}
}

if (oversizedFiles.length > 0) {
Alert.alert(
'File Too Large',
`The following files exceed size limits:\n${oversizedFiles.join('\n')}`
);
}

const availableSlots = 4 - selectedAssets.length;
const assetsToAdd = newAssets
const assetsToAdd = validAssets
.filter(
(asset) =>
!selectedAssets.some((a) => (a.fileName || a.id) === (asset.fileName || asset.id))
)
.slice(0, availableSlots);

setSelectedAssets([...assetsToAdd, ...selectedAssets]);
if (assetsToAdd.length > 0) {
setSelectedAssets([...assetsToAdd, ...selectedAssets]);

const newForAllPicked = assetsToAdd.filter(
(asset) => !allPickedAssets.some((a) => a.fileName === asset.fileName)
);
setAllPickedAssets([...allPickedAssets, ...newForAllPicked]);
const newForAllPicked = assetsToAdd.filter(
(asset) => !allPickedAssets.some((a) => a.fileName === asset.fileName)
);
setAllPickedAssets([...allPickedAssets, ...newForAllPicked]);
}
}
} finally {
setIsPickingMedia(false);
Expand Down Expand Up @@ -279,12 +323,21 @@ export default function TweetComposer({
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
quality: 1,
allowsEditing: true,
exif: false,
});

if (!result.canceled) {
const asset = result.assets[0];
const sizeCheck = await checkFileSize(asset.uri, false);

if (!sizeCheck.valid) {
Alert.alert(
'Image Too Large',
`This image is ${sizeCheck.sizeMB?.toFixed(1)}MB. Maximum size is 5MB.`
);
return;
}

const newAsset: Asset = {
id: asset.uri,
uri: asset.uri,
Expand Down Expand Up @@ -319,6 +372,16 @@ export default function TweetComposer({

if (!result.canceled) {
const asset = result.assets[0];
const sizeCheck = await checkFileSize(asset.uri, true);

if (!sizeCheck.valid) {
Alert.alert(
'Video Too Large',
`This video is ${sizeCheck.sizeMB?.toFixed(1)}MB. Maximum size is 10MB.`
);
return;
}

const newAsset: Asset = {
id: asset.uri,
uri: asset.uri,
Expand Down
27 changes: 21 additions & 6 deletions src/hooks/useMediaLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import { Platform } from 'react-native';

import * as MediaLibrary from 'expo-media-library';

const hasMediaPermission = (response: MediaLibrary.PermissionResponse | null): boolean => {
return response?.granted ?? false;
};

export function useMediaLibrary() {
const [albums, setAlbums] = useState<MediaLibrary.Album[] | null>(null);
const [assets, setAssets] = useState<MediaLibrary.Asset[]>([]);
const [loading, setLoading] = useState(false);
const [hasNextPage, setHasNextPage] = useState(false);
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions();
const loadingRef = useRef(false);
const hasRequestedPermission = useRef(false);

const fetchAlbums = useCallback(
async (options: MediaLibrary.AlbumsOptions = { includeSmartAlbums: true }) => {
Expand All @@ -21,13 +26,19 @@ export function useMediaLibrary() {

try {
let response = permissionResponse;
if (!response || response.status !== 'granted') response = await requestPermission();
// Only request permission if we haven't already and user can be asked
if (!hasMediaPermission(response) && !hasRequestedPermission.current) {
if (!response || response.canAskAgain !== false) {
hasRequestedPermission.current = true;
response = await requestPermission();
}
}

if (response?.granted) {
if (hasMediaPermission(response)) {
const fetchedAlbums = await MediaLibrary.getAlbumsAsync(options);
setAlbums(fetchedAlbums);
} else {
alert('Permission denied');
console.warn('Media library permission not granted');
}
} catch (error) {
console.error('Error fetching albums:', error);
Expand All @@ -46,11 +57,15 @@ export function useMediaLibrary() {
setLoading(true);
try {
let response = permissionResponse;
if (!response || response.status !== 'granted') {
response = await requestPermission();
// Only request permission if we haven't already and user can be asked
if (!hasMediaPermission(response) && !hasRequestedPermission.current) {
if (!response || response.canAskAgain !== false) {
hasRequestedPermission.current = true;
response = await requestPermission();
}
}

if (response?.granted) {
if (hasMediaPermission(response)) {
const result = await MediaLibrary.getAssetsAsync(options);

// On iOS, convert ph:// URIs to file:// URIs for React Native Image component
Expand Down
Loading