diff --git a/src/app/(auth)/(tabs)/index.tsx b/src/app/(auth)/(tabs)/index.tsx index 6a002428..f39e1c40 100644 --- a/src/app/(auth)/(tabs)/index.tsx +++ b/src/app/(auth)/(tabs)/index.tsx @@ -10,13 +10,7 @@ import { Text, View, XStack, Spinner, YStack } from 'tamagui' import FeedPost from 'src/components/post/FeedPost' import { StatusBar } from 'expo-status-bar' import { SafeAreaView } from 'react-native-safe-area-context' -import { - type ErrorBoundaryProps, - Stack, - useLocalSearchParams, - useNavigation, - useRouter, -} from 'expo-router' +import { Stack, useLocalSearchParams, useNavigation, useRouter } from 'expo-router' import { useInfiniteQuery, useMutation, @@ -41,6 +35,8 @@ import { useVideo } from 'src/hooks/useVideoProvider' import { useFocusEffect } from '@react-navigation/native' import { useUserCache } from 'src/state/AuthProvider' import type { Status } from 'src/lib/api-types' +import type { ListRenderItemInfo } from 'react-native' +import type { ErrorBoundaryProps } from 'expo-router' export function ErrorBoundary(props: ErrorBoundaryProps) { return ( @@ -159,12 +155,12 @@ export default function HomeScreen() { const keyExtractor = useCallback((item) => item?.id, []) - const onDeletePost = (id) => { + const onDeletePost = (id: string) => { deletePostMutation.mutate(id) } const deletePostMutation = useMutation({ - mutationFn: async (id) => { + mutationFn: async (id: string) => { return await deleteStatusV1(id) }, onSuccess: (data, variables) => { @@ -182,16 +178,16 @@ export default function HomeScreen() { }) const bookmarkMutation = useMutation({ - mutationFn: async (id) => { + mutationFn: async (id: string) => { return await postBookmark(id) }, }) - const onBookmark = (id) => { + const onBookmark = (id: string) => { bookmarkMutation.mutate(id) } - const onShare = (id, state) => { + const onShare = (id: string, state) => { try { shareMutation.mutate({ type: state == true ? 'unreblog' : 'reblog', id: id }) } catch (error) { @@ -220,22 +216,22 @@ export default function HomeScreen() { router.push(`/post/likes/${id}`) } - const handleGotoProfile = (id) => { + const handleGotoProfile = (id: string) => { bottomSheetModalRef.current?.close() router.push(`/profile/${id}`) } - const handleGotoUsernameProfile = (id) => { + const handleGotoUsernameProfile = (username: string) => { bottomSheetModalRef.current?.close() - router.push(`/profile/0?byUsername=${id}`) + router.push(`/profile/0?byUsername=${username}`) } - const gotoHashtag = (id) => { + const gotoHashtag = (id: string) => { bottomSheetModalRef.current?.close() router.push(`/hashtag/${id}`) } - const handleCommentReport = (id) => { + const handleCommentReport = (id: string) => { bottomSheetModalRef.current?.close() router.push(`/post/report/${id}`) } diff --git a/src/app/(auth)/(tabs)/profile.tsx b/src/app/(auth)/(tabs)/profile.tsx index fe7aebd3..6d0511af 100644 --- a/src/app/(auth)/(tabs)/profile.tsx +++ b/src/app/(auth)/(tabs)/profile.tsx @@ -40,7 +40,10 @@ export default function ProfileScreen() { } = useInfiniteQuery({ queryKey: ['statusesById', userId], queryFn: async ({ pageParam }) => { - const data = await getAccountStatusesById(userId, pageParam) + if (!userId) { + throw new Error('getAccountStatusesById: user id missing') + } + const data = await getAccountStatusesById(userId, { max_id: pageParam }) return data.filter((p) => { return ( ['photo', 'photo:album', 'video'].includes(p.pf_type) && @@ -111,6 +114,7 @@ export default function ProfileScreen() { )} + item?.id.toString()} diff --git a/src/app/(auth)/admin/users/show/[id].tsx b/src/app/(auth)/admin/users/show/[id].tsx index ff52de90..c5a89141 100644 --- a/src/app/(auth)/admin/users/show/[id].tsx +++ b/src/app/(auth)/admin/users/show/[id].tsx @@ -11,7 +11,7 @@ import UserAvatar from 'src/components/common/UserAvatar' import { PressableOpacity } from 'react-native-pressable-opacity' export default function Screen() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const router = useRouter() const instance = Storage.getString('app.instance') diff --git a/src/app/(auth)/chats/conversation/[id].tsx b/src/app/(auth)/chats/conversation/[id].tsx index 701143d7..ca71c860 100644 --- a/src/app/(auth)/chats/conversation/[id].tsx +++ b/src/app/(auth)/chats/conversation/[id].tsx @@ -6,16 +6,13 @@ import { Stack, useLocalSearchParams, useNavigation, useRouter } from 'expo-rout import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { fetchChatThread, sendChatMessage, deleteChatMessage } from 'src/lib/api' import { _timeAgo, enforceLen } from 'src/utils' -import { - GiftedChat, - Bubble, - Send, - type BubbleProps, - type IMessage, -} from 'react-native-gifted-chat' +import { GiftedChat, Bubble, Send } from 'react-native-gifted-chat' + import { Feather } from '@expo/vector-icons' import { useUserCache } from 'src/state/AuthProvider' +import type { BubbleProps, IMessage } from 'react-native-gifted-chat' + function renderBubble(props: BubbleProps) { return ( (props: BubbleProps) { } export default function Page() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const router = useRouter() const queryClient = useQueryClient() const navigation = useNavigation() @@ -45,20 +42,35 @@ export default function Page() { const sendMutation = useMutation({ mutationFn: async (message) => { - const res = await sendChatMessage(id, message[0].text) - const msg = { - _id: res.id, - id: res.id, - createdAt: new Date(), - sent: true, - text: message[0].text, - user: { - _id: selfUser.id, - name: selfUser.username, - avatar: selfUser.avatar, - }, + try { + const res = await sendChatMessage(id, message[0].text) + console.log('send message - server answered:', { res }) + + if (typeof res.error !== 'undefined') { + throw new Error(res.error) + } + + const msg = { + _id: res.id, + id: res.id, + createdAt: new Date(), + sent: true, + text: message[0].text, + user: { + _id: selfUser.id, + name: selfUser.username, + avatar: selfUser.avatar, + }, + } + setMessages((previousMessages) => GiftedChat.append(previousMessages, msg)) + } catch (err: any) { + console.log('Failed to send message', err, err?.msg || err?.message) + Alert.alert('Failed to send message', err?.message || err) } - setMessages((previousMessages) => GiftedChat.append(previousMessages, msg)) + }, + onError: (err) => { + console.log('sendMutation: Failed to send message', err) + Alert.alert('Failed to send message', err.message) }, }) diff --git a/src/app/(auth)/hashtag/[id].tsx b/src/app/(auth)/hashtag/[id].tsx index 3843a567..a40e8d7c 100644 --- a/src/app/(auth)/hashtag/[id].tsx +++ b/src/app/(auth)/hashtag/[id].tsx @@ -54,7 +54,7 @@ const RenderItem = ({ item }) => ) : null export default function Page() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const queryClient = useQueryClient() const RelatedTags = useCallback( diff --git a/src/app/(auth)/post/[id].tsx b/src/app/(auth)/post/[id].tsx index 242dbea8..18e76e8b 100644 --- a/src/app/(auth)/post/[id].tsx +++ b/src/app/(auth)/post/[id].tsx @@ -12,7 +12,7 @@ import CommentFeed from 'src/components/post/CommentFeed' import { useUserCache } from 'src/state/AuthProvider' export default function Page() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const navigation = useNavigation() useLayoutEffect(() => { @@ -83,7 +83,7 @@ export default function Page() { const { isPending, isError, data, error } = useQuery({ queryKey: ['getStatusById', id], - queryFn: getStatusById, + queryFn: () => getStatusById(id), }) if (isPending) { return ( diff --git a/src/app/(auth)/post/edit/[id].tsx b/src/app/(auth)/post/edit/[id].tsx index 4e8fe54d..3ca50908 100644 --- a/src/app/(auth)/post/edit/[id].tsx +++ b/src/app/(auth)/post/edit/[id].tsx @@ -63,7 +63,7 @@ const RenderItem = React.memo(({ item, onUpdateMediaAlt }) => ( )) export default function Page() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const navigation = useNavigation() const [caption, setCaption] = useState('') const [isSensitive, setSensitive] = useState(false) @@ -111,6 +111,8 @@ export default function Page() { return m.description !== ogm.description }) + // TODO: error handling + // TODO: invalidate react query cache of this post so it is updated in the UI await Promise.all(mediaChanges.map(updateMedia)) .then(async (res) => { return await putEditPost(data?.id, { diff --git a/src/app/(auth)/post/history/[id].tsx b/src/app/(auth)/post/history/[id].tsx index 2f7b9129..618e589d 100644 --- a/src/app/(auth)/post/history/[id].tsx +++ b/src/app/(auth)/post/history/[id].tsx @@ -9,7 +9,7 @@ import { _timeAgo, htmlToTextWithLineBreaks } from 'src/utils' import ReadMore from 'src/components/common/ReadMore' export default function Page() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const navigation = useNavigation() useLayoutEffect(() => { diff --git a/src/app/(auth)/post/likes/[id].tsx b/src/app/(auth)/post/likes/[id].tsx index d9a943e7..0e762474 100644 --- a/src/app/(auth)/post/likes/[id].tsx +++ b/src/app/(auth)/post/likes/[id].tsx @@ -7,7 +7,7 @@ import { getStatusById, getStatusLikes } from 'src/lib/api' import UserAvatar from 'src/components/common/UserAvatar' export default function Page() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const RenderItem = ({ item }) => { return ( @@ -35,7 +35,7 @@ export default function Page() { const { data: status } = useQuery({ queryKey: ['getStatusById', id], - queryFn: getStatusById, + queryFn: () => getStatusById(id), }) const statusId = status?.id diff --git a/src/app/(auth)/post/report/[id].tsx b/src/app/(auth)/post/report/[id].tsx index ae8fd181..0077709d 100644 --- a/src/app/(auth)/post/report/[id].tsx +++ b/src/app/(auth)/post/report/[id].tsx @@ -3,26 +3,18 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { ScrollView, Text, XStack, YStack } from 'tamagui' import { Feather } from '@expo/vector-icons' import { useMutation } from '@tanstack/react-query' -import { reportStatus } from 'src/lib/api' -import { ActivityIndicator, Pressable } from 'react-native' +import { report } from 'src/lib/api' +import { ActivityIndicator, Alert, Pressable } from 'react-native' +import { reportTypes } from 'src/lib/reportTypes' + +import type { NewReport } from 'src/lib/api' +import type { ReportType } from 'src/lib/reportTypes' export default function Page() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const router = useRouter() - const reportTypes = [ - { name: 'spam', title: "It's spam" }, - { name: 'sensitive', title: 'Nudity or sexual activity' }, - { name: 'abusive', title: 'Bullying or harassment' }, - { name: 'underage', title: 'I think this account is underage' }, - { name: 'violence', title: 'Violence or dangerous organizations' }, - { name: 'copyright', title: 'Copyright infringement' }, - { name: 'impersonation', title: 'Impersonation' }, - { name: 'scam', title: 'Scam or fraud' }, - { name: 'terrorism', title: 'Terrorism or terrorism-related content' }, - ] - - const RenderOption = ({ title, name }) => ( + const RenderOption = ({ title, name }: ReportType) => ( handleAction(name)}> ) - const handleAction = (type) => { - mutation.mutate({ id: id, type: type }) + const handleAction = (type: string) => { + mutation.mutate({ object_id: id, object_type: 'post', report_type: type }) } const mutation = useMutation({ - mutationFn: (newReport) => { - return reportStatus(newReport) + mutationFn: (newReport: NewReport) => { + return report(newReport) }, onSuccess: (data, variables, context) => { router.replace('/post/report/sent') }, + onError: (err) => { + Alert.alert('Report Failed', err.message) + }, }) return ( diff --git a/src/app/(auth)/post/shares/[id].tsx b/src/app/(auth)/post/shares/[id].tsx index a77adcac..88332360 100644 --- a/src/app/(auth)/post/shares/[id].tsx +++ b/src/app/(auth)/post/shares/[id].tsx @@ -7,7 +7,7 @@ import { getStatusById, getStatusReblogs } from 'src/lib/api' import UserAvatar from 'src/components/common/UserAvatar' export default function Page() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const RenderItem = ({ item }) => { return ( @@ -35,7 +35,7 @@ export default function Page() { const { data: status } = useQuery({ queryKey: ['getStatusById', id], - queryFn: getStatusById, + queryFn: () => getStatusById(id), }) const statusId = status?.id diff --git a/src/app/(auth)/profile/[id].tsx b/src/app/(auth)/profile/[id].tsx index 62985848..3bafee1c 100644 --- a/src/app/(auth)/profile/[id].tsx +++ b/src/app/(auth)/profile/[id].tsx @@ -48,7 +48,7 @@ const SCREEN_WIDTH = Dimensions.get('screen').width export default function ProfileScreen() { const navigation = useNavigation() - const { id, byUsername } = useLocalSearchParams() + const { id, byUsername } = useLocalSearchParams<{ id: string; byUsername?: string }>() const queryClient = useQueryClient() const bottomSheetModalRef = useRef(null) const snapPoints = useMemo(() => ['50%', '55%'], []) @@ -332,7 +332,7 @@ export default function ProfileScreen() { bottomSheetModalRef.current?.close() if (action === 'report') { - router.push('/profile/report/' + id) + router.push('/profile/report/' + userId) } if (action === 'block') { @@ -347,7 +347,7 @@ export default function ProfileScreen() { { text: 'Block', style: 'destructive', - onPress: () => _handleBlock(), + onPress: () => handleBlock(), }, ] ) @@ -362,7 +362,7 @@ export default function ProfileScreen() { { text: 'Unblock', style: 'destructive', - onPress: () => _handleUnblock(), + onPress: () => handleUnblock(), }, ]) } @@ -379,7 +379,7 @@ export default function ProfileScreen() { { text: 'Mute', style: 'destructive', - onPress: () => _handleMute(), + onPress: () => handleMute(), }, ] ) @@ -394,7 +394,7 @@ export default function ProfileScreen() { { text: 'Unmute', style: 'destructive', - onPress: () => _handleUnmute(), + onPress: () => handleUnmute(), }, ]) } @@ -426,35 +426,35 @@ export default function ProfileScreen() { } } - const _handleBlock = () => { + const handleBlock = () => { blockMutation.mutate() } - const _handleUnblock = () => { + const handleUnblock = () => { unblockMutation.mutate() } - const _handleMute = () => { + const handleMute = () => { muteMutation.mutate() } - const _handleUnmute = () => { + const handleUnmute = () => { unmuteMutation.mutate() } - const _handleFollow = () => { + const handleFollow = () => { followMutation.mutate() } - const _handleUnfollow = () => { + const handleUnfollow = () => { unfollowMutation.mutate() } - const _handleCancelFollowRequest = () => { + const handleCancelFollowRequest = () => { unfollowMutation.mutate() } - const _handleOnShare = async () => { + const handleOnShare = async () => { try { const result = await Share.share({ message: user.url, @@ -466,13 +466,19 @@ export default function ProfileScreen() { } } - const { data: user, error: userError } = useQuery({ - queryKey: - byUsername !== undefined && id == 0 - ? ['getAccountByUsername', byUsername] - : ['getAccountById', id], - queryFn: byUsername !== undefined && id == 0 ? getAccountByUsername : getAccountById, - }) + const { data: user, error: userError } = useQuery( + byUsername !== undefined && id === '0' + ? { + queryKey: ['getAccountByUsername', byUsername], + queryFn: () => getAccountByUsername(byUsername), + } + : { + queryKey: ['getAccountById', id], + queryFn: () => getAccountById(id), + } + ) + + const userId = user?.id useEffect(() => { if (user && Platform.OS == 'android') { @@ -487,8 +493,6 @@ export default function ProfileScreen() { } }, [navigation, user]) - const userId = user?.id - const { data: relationship, isError: relationshipError } = useQuery({ queryKey: ['getAccountRelationship', userId], queryFn: getAccountRelationship, @@ -507,10 +511,10 @@ export default function ProfileScreen() { profile={user} relationship={relationship} openMenu={onOpenMenu} - onFollow={() => _handleFollow()} - onUnfollow={() => _handleUnfollow()} - onCancelFollowRequest={() => _handleCancelFollowRequest()} - onShare={() => _handleOnShare()} + onFollow={() => handleFollow()} + onUnfollow={() => handleUnfollow()} + onCancelFollowRequest={() => handleCancelFollowRequest()} + onShare={() => handleOnShare()} mutuals={mutuals} /> ), @@ -557,6 +561,34 @@ export default function ProfileScreen() { enabled: !!userId, }) + if (userError) { + return ( + + + + {userError.message} + {byUsername && ( + + )} + + + ) + } + if (status !== 'success' || (isFetching && !isFetchingNextPage)) { return ( diff --git a/src/app/(auth)/profile/about/[id].tsx b/src/app/(auth)/profile/about/[id].tsx index 59b77e6a..d967e0fa 100644 --- a/src/app/(auth)/profile/about/[id].tsx +++ b/src/app/(auth)/profile/about/[id].tsx @@ -9,7 +9,7 @@ import { getAccountById, getAccountRelationship } from 'src/lib/api' import { formatTimestampMonthYear, formatTimestamp, getDomain } from 'src/utils' export default function ProfileScreen() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const router = useRouter() const { @@ -18,7 +18,7 @@ export default function ProfileScreen() { isFetching: isFetchingUser, } = useQuery({ queryKey: ['getAccountById', id], - queryFn: getAccountById, + queryFn: () => getAccountById(id), }) if (userError) { diff --git a/src/app/(auth)/profile/followers/[id].tsx b/src/app/(auth)/profile/followers/[id].tsx index 1d2243e5..dd23f040 100644 --- a/src/app/(auth)/profile/followers/[id].tsx +++ b/src/app/(auth)/profile/followers/[id].tsx @@ -9,7 +9,7 @@ import UserAvatar from 'src/components/common/UserAvatar' import Feather from '@expo/vector-icons/Feather' export default function FollowersScreen() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const navigation = useNavigation() useLayoutEffect(() => { navigation.setOptions({ title: 'Followers', headerBackTitle: 'Back' }) @@ -36,7 +36,7 @@ export default function FollowersScreen() { const { data: profile } = useQuery({ queryKey: ['getAccountById', id], - queryFn: getAccountById, + queryFn: () => getAccountById(id), }) const profileId = profile?.id diff --git a/src/app/(auth)/profile/following/[id].tsx b/src/app/(auth)/profile/following/[id].tsx index d7fa2927..aac90a84 100644 --- a/src/app/(auth)/profile/following/[id].tsx +++ b/src/app/(auth)/profile/following/[id].tsx @@ -9,7 +9,7 @@ import UserAvatar from 'src/components/common/UserAvatar' import Feather from '@expo/vector-icons/Feather' export default function FollowingScreen() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const navigation = useNavigation() useLayoutEffect(() => { navigation.setOptions({ title: 'Following', headerBackTitle: 'Back' }) @@ -36,7 +36,7 @@ export default function FollowingScreen() { const { data: profile } = useQuery({ queryKey: ['getAccountById', id], - queryFn: getAccountById, + queryFn: () => getAccountById(id), }) const profileId = profile?.id diff --git a/src/app/(auth)/profile/report/[id].tsx b/src/app/(auth)/profile/report/[id].tsx index fc71bfa9..a2648d50 100644 --- a/src/app/(auth)/profile/report/[id].tsx +++ b/src/app/(auth)/profile/report/[id].tsx @@ -3,26 +3,18 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { ScrollView, Text, View, XStack, YStack } from 'tamagui' import { Feather } from '@expo/vector-icons' import { useMutation } from '@tanstack/react-query' -import { reportProfile } from 'src/lib/api' -import { ActivityIndicator, Pressable } from 'react-native' +import { report } from 'src/lib/api' +import { ActivityIndicator, Alert, Pressable } from 'react-native' +import { reportTypes } from 'src/lib/reportTypes' + +import type { NewReport } from 'src/lib/api' +import type { ReportType } from 'src/lib/reportTypes' export default function Page() { - const { id } = useLocalSearchParams() + const { id } = useLocalSearchParams<{ id: string }>() const router = useRouter() - const reportTypes = [ - { name: 'spam', title: "It's spam" }, - { name: 'sensitive', title: 'Nudity or sexual activity' }, - { name: 'abusive', title: 'Bullying or harassment' }, - { name: 'underage', title: 'I think this account is underage' }, - { name: 'violence', title: 'Violence or dangerous organizations' }, - { name: 'copyright', title: 'Copyright infringement' }, - { name: 'impersonation', title: 'Impersonation' }, - { name: 'scam', title: 'Scam or fraud' }, - { name: 'terrorism', title: 'Terrorism or terrorism-related content' }, - ] - - const RenderOption = ({ title, name }) => ( + const RenderOption = ({ title, name }: ReportType) => ( handleAction(name)}> ) - const handleAction = (type) => { - mutation.mutate({ id: id, type: type }) + const handleAction = (type: string) => { + mutation.mutate({ object_id: id, object_type: 'user', report_type: type }) } const mutation = useMutation({ - mutationFn: (newReport) => { - return reportProfile(newReport) + mutationFn: (newReport: NewReport) => { + return report(newReport) }, onSuccess: (data, variables, context) => { router.replace('/profile/report/sent') }, + onError: (err) => { + Alert.alert('Report Failed', err.message) + }, }) return ( diff --git a/src/app/(auth)/search/index.tsx b/src/app/(auth)/search/index.tsx index 321406c1..93783929 100644 --- a/src/app/(auth)/search/index.tsx +++ b/src/app/(auth)/search/index.tsx @@ -1,4 +1,4 @@ -import { Link, Stack } from 'expo-router' +import { Link, Stack, useLocalSearchParams } from 'expo-router' import { FlatList, ActivityIndicator, Keyboard, Pressable } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { Text, View, YStack, Input, XStack } from 'tamagui' @@ -13,7 +13,9 @@ import ReadMore from '../../../components/common/ReadMore' import { formatTimestampMonthYear, postCountLabel } from '../../../utils' export default function SearchScreen() { - const [query, setQuery] = useState('') + const { initialQuery } = useLocalSearchParams<{ initialQuery?: string }>() + + const [query, setQuery] = useState(initialQuery || '') const { data, isLoading, isError, error, isFetching } = useQuery({ queryKey: ['search', query], diff --git a/src/lib/api-context.ts b/src/lib/api-context.ts new file mode 100644 index 00000000..48807248 --- /dev/null +++ b/src/lib/api-context.ts @@ -0,0 +1,118 @@ +import { Storage } from 'src/state/cache' +import { randomKey } from './randomKey' + +type ApiRequestOptions = { + idempotency?: true + searchParams?: Record +} + +export class ApiContext { + constructor( + public readonly instanceHostName: string, + public readonly token: string + ) { + if (instanceHostName.length === 0) { + throw new Error('failed to create ApiContext: instance host name missing') + } + if (token.length === 0) { + throw new Error('failed to create ApiContext: token missing') + } + } + + // TODO: private + async request( + path: string, + fetch_options: RequestInit, + options?: ApiRequestOptions + ): Promise { + const url = new URL(`${path}`, `https://${this.instanceHostName}`) + + if (options?.searchParams) { + let { searchParams } = options + for (const key in searchParams) { + if (Object.prototype.hasOwnProperty.call(searchParams, key)) { + url.searchParams.append(key, String(searchParams[key])) + } + } + } + + fetch_options.headers = { + ...fetch_options.headers, + Accept: 'application/json', + Authorization: `Bearer ${this.token}`, + ...(options?.idempotency ? { 'Idempotency-Key': randomKey(40) } : {}), + } + + let response = await fetch(url, fetch_options) + if (!response.ok) { + let errorResponse + try { + errorResponse = await response.json() + console.warn('API Request Failed', { errorResponse }) + if ( + !errorResponse.error_code && + (typeof errorResponse.error === 'undefined' || errorResponse.error.length === 0) + ) { + throw new Error('request failed, but error field is empty') + } + } catch (error) { + console.error('API Request Failed - Failed to decode error:', error) + throw new Error('API Request Failed without error message') + } + if (errorResponse.error_code) { + throw new Error( + `API Request Failed: ${errorResponse.error_code}: ${errorResponse.msg}` + ) + } + throw new Error(`API Request Failed: ${errorResponse.error}`) + } + + return response + } + + /** request in json data and gets back json data */ + async jsonRequest( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + data = {}, + searchParams: ApiRequestOptions['searchParams'] = {} + ) { + return await ( + await this.request( + path, + { + method, + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' }, + }, + { searchParams } + ) + ).json() + } + + async get(url: string, searchParams: ApiRequestOptions['searchParams'] = {}) { + const response = await this.request( + url, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + { searchParams } + ) + return await response.json() + } +} + +export function ContextFromStorage(): ApiContext { + const instance = Storage.getString('app.instance') + const token = Storage.getString('app.token') + if (!instance || !token) { + throw new Error('api token or instance undefined') + } + return new ApiContext(instance, token) +} + +// For debugging +;(global as any).API = ContextFromStorage diff --git a/src/lib/api-types.d.ts b/src/lib/api-types.d.ts index e463f9a6..81ee9b7a 100644 --- a/src/lib/api-types.d.ts +++ b/src/lib/api-types.d.ts @@ -100,16 +100,29 @@ export type Relationship = { blocking: boolean domain_blocking endorsed: boolean - followed_by? - following + followed_by?: boolean + following: boolean following_since id - muting + muting: boolean muting_notifications - requested + requested: boolean showing_reblogs } +export type RelationshipFromFollowAPIResponse = { + blocking: boolean + domain_blocking: any | null + endorsed: boolean + followed_by?: boolean + following: boolean + id: string + muting: boolean + muting_notifications: any | null + requested: boolean + showing_reblogs: any | null +} + export type Account = { /** value is username */ acct: string diff --git a/src/lib/api.ts b/src/lib/api.ts index 1011f766..424b9cda 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,7 +1,16 @@ import { objectToForm } from 'src/requests' import { Storage } from 'src/state/cache' import { parseLinkHeader } from 'src/utils' -import type { PaginatedStatus, Relationship, UpdateCredentialsParams } from './api-types' +import type { + Account, + PaginatedStatus, + Relationship, + RelationshipFromFollowAPIResponse, + UpdateCredentialsParams, + Status, +} from './api-types' +import { randomKey } from './randomKey' +import { ContextFromStorage } from './api-context' export function randomKey(length: number) { let result = '' @@ -59,35 +68,6 @@ export async function selfPost( return rawRes ? resp : resp.json() } -export async function selfPut( - path: string, - params = {}, - asForm = false, - rawRes = false, - idempotency = false -) { - let headers: Record = {} - const instance = Storage.getString('app.instance') - const token = Storage.getString('app.token') - const url = `https://${instance}/${path}` - - headers['Authorization'] = `Bearer ${token}` - headers['Accept'] = 'application/json' - headers['Content-Type'] = asForm ? 'multipart/form-data' : 'application/json' - - if (idempotency) { - headers['Idempotency-Key'] = randomKey(40) - } - - const resp = await fetch(url, { - method: 'PUT', - body: asForm ? objectToForm(params) : JSON.stringify(params), - headers, - }) - - return rawRes ? resp : resp.json() -} - export async function selfDelete( path: string, params = {}, @@ -183,6 +163,8 @@ async function fetchCursorPagination(url: string) { } async function fetchData(url: string) { + console.log('deprected fetchData called', url) + const token = Storage.getString('app.token') const response = await fetch(url, { @@ -294,59 +276,72 @@ export async function getAccountFollowing(id: string, cursor) { return await fetchPaginatedData(url) } -export async function getStatusById({ queryKey }) { - const instance = Storage.getString('app.instance') - const url = `https://${instance}/api/v1/statuses/${queryKey[1]}?_pe=1` - return await fetchData(url) +export async function getStatusById(id: string) { + const api = ContextFromStorage() + return await api.get(`api/v1/statuses/${id}?_pe=1`) } -export async function getAccountById({ queryKey }) { - const instance = Storage.getString('app.instance') - const url = `https://${instance}/api/v1/accounts/${queryKey[1]}?_pe=1` - return await fetchData(url) +export async function getAccountById(id: string) { + const api = ContextFromStorage() + return await api.get(`api/v1/accounts/${id}?_pe=1`) } -export async function followAccountById(id: string) { - let path = `api/v1/accounts/${id}/follow` - return await selfPost(path) +export async function followAccountById( + id: string +): Promise { + const api = ContextFromStorage() + return await api.jsonRequest('POST', `api/v1/accounts/${id}/follow`) } -export async function unfollowAccountById(id: string) { - let path = `api/v1/accounts/${id}/unfollow` - return await selfPost(path) +export async function unfollowAccountById( + id: string +): Promise { + const api = ContextFromStorage() + return await api.jsonRequest('POST', `api/v1/accounts/${id}/unfollow`) } -export async function reportProfile({ id, type }) { - const instance = Storage.getString('app.instance') - const token = Storage.getString('app.token') - - const params = new URLSearchParams({ - report_type: type, - object_type: 'user', - object_id: id, - }) - const url = `https://${instance}/api/v1.1/report?${params}` - const response = await fetch(url, { - method: 'post', - headers: new Headers({ - Authorization: `Bearer ${token}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }), - }) - return await response.json() +export type NewReport = { + object_id: string + report_type: string + object_type: 'user' | 'post' } -export async function getAccountByUsername({ queryKey }) { - const instance = Storage.getString('app.instance') - const url = `https://${instance}/api/v1.1/accounts/username/${queryKey[1]}?_pe=1` - return await fetchData(url) +export async function report(report: NewReport) { + const api = ContextFromStorage() + const response = await api.jsonRequest('POST', 'api/v1.1/report', {}, report) + return await response.json() } -export async function getAccountStatusesById(id: string, page) { - const instance = Storage.getString('app.instance') - const url = `https://${instance}/api/v1/accounts/${id}/statuses?_pe=1&limit=24&max_id=${page}` - return await fetchData(url) +export async function getAccountByUsername(username: string): Promise { + const api = ContextFromStorage() + let account = await api.get(`api/v1.1/accounts/username/${username}?_pe=1`) + if (Array.isArray(account) && account.length === 0) { + throw new Error(`Account "${username}" not found`) + } + return account +} + +interface getAccountStatusesByIdParameters { + // https://github.com/pixelfed/pixelfed/blob/fa4474bc38d64b1d96272f9d45e90289020fcb11/app/Http/Controllers/Api/ApiV1Controller.php#L699 + only_media?: true + pinned?: true + exclude_replies?: true + media_type?: 'photo' | 'video' + limit?: number + max_id?: number + since_id?: number + min_id?: number +} + +export async function getAccountStatusesById( + id: string, + parameters: getAccountStatusesByIdParameters +): Promise { + const api = ContextFromStorage() + return await api.get(`api/v1/accounts/${id}/statuses`, { + _pe: 1, // todo document what _pe means + ...parameters, + }) } export async function getHashtagByName({ queryKey }) { @@ -525,27 +520,6 @@ export async function unreblogStatus({ id }: { id: string }) { return await selfPost(`api/v1/statuses/${id}/unreblog`) } -export async function reportStatus({ id, type }) { - const instance = Storage.getString('app.instance') - const token = Storage.getString('app.token') - - const params = new URLSearchParams({ - report_type: type, - object_type: 'post', - object_id: id, - }) - const url = `https://${instance}/api/v1.1/report?${params}` - const response = await fetch(url, { - method: 'post', - headers: new Headers({ - Authorization: `Bearer ${token}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }), - }) - return await response.json() -} - export async function deleteStatus({ id }: { id: string }) { const instance = Storage.getString('app.instance') const token = Storage.getString('app.token') @@ -625,9 +599,10 @@ export async function getFollowRequests() { } export async function getSelfAccount() { - const instance = Storage.getString('app.instance') - let url = `https://${instance}/api/v1/accounts/verify_credentials?_pe=1` - return await fetchData(url) + const api = ContextFromStorage() + return await api.get('api/v1/accounts/verify_credentials', { + _pe: 1, // todo document what _pe means + }) } export async function updateCredentials(params: URLSearchParams) { @@ -693,7 +668,7 @@ export async function deleteChatMessage(id: string) { return await selfDelete(path) } -export async function sendChatMessage(id: string, message) { +export async function sendChatMessage(id: string, message: string) { const path = `api/v1.1/direct/thread/send` return await selfPost(path, { to_id: id, @@ -861,7 +836,8 @@ export async function getSelfBookmarks({ pageParam = false }) { } export async function putEditPost(id: string, params) { - return await selfPut(`api/v1/statuses/${id}`, params) + let api = ContextFromStorage() + return await api.jsonRequest('PUT', `api/v1/statuses/${id}`, params) } export async function getStoryCarousel() { diff --git a/src/lib/randomKey.ts b/src/lib/randomKey.ts new file mode 100644 index 00000000..161e9433 --- /dev/null +++ b/src/lib/randomKey.ts @@ -0,0 +1,11 @@ +export function randomKey(length: number) { + let result = '' + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const charactersLength = characters.length + let counter = 0 + while (counter < length) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)) + counter += 1 + } + return result +} diff --git a/src/lib/reportTypes.ts b/src/lib/reportTypes.ts new file mode 100644 index 00000000..b4b573f0 --- /dev/null +++ b/src/lib/reportTypes.ts @@ -0,0 +1,13 @@ +export type ReportType = { name: string; title: string } + +export const reportTypes: ReportType[] = [ + { name: 'spam', title: "It's spam" }, + { name: 'sensitive', title: 'Nudity or sexual activity' }, + { name: 'abusive', title: 'Bullying or harassment' }, + { name: 'underage', title: 'I think this account is underage' }, + { name: 'violence', title: 'Violence or dangerous organizations' }, + { name: 'copyright', title: 'Copyright infringement' }, + { name: 'impersonation', title: 'Impersonation' }, + { name: 'scam', title: 'Scam or fraud' }, + { name: 'terrorism', title: 'Terrorism or terrorism-related content' }, +] diff --git a/src/state/AuthProvider.tsx b/src/state/AuthProvider.tsx index a6dd1602..6c0c19ea 100644 --- a/src/state/AuthProvider.tsx +++ b/src/state/AuthProvider.tsx @@ -267,7 +267,7 @@ export function useQuerySelfProfile() { isFetchedAfterMount, } = useQuery({ queryKey: ['profileById', userCache.id], - queryFn: getAccountById, + queryFn: () => getAccountById(userCache.id), placeholderData: userCache, })