diff --git a/src/App.jsx b/src/App.jsx index 414e729..936e3b8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -16,6 +16,7 @@ import EditProfileScreen from './screens/EditProfileScreen'; import WriterScreen from './screens/WriterScreen'; import MessageScreen from './screens/MessagesScreen'; import ThreadScreen from './screens/ThreadScreen'; +import UserProfileScreen from './screens/UserProfileScreen'; import { storage, hasMigratedFromAsyncStorage, migrateFromAsyncStorage } from './utils/mmkv'; import { useMMKVBoolean, useMMKVString } from 'react-native-mmkv'; @@ -101,6 +102,7 @@ export default function App() { + diff --git a/src/components/Comment.jsx b/src/components/Comment.jsx index f353a99..c5dea77 100644 --- a/src/components/Comment.jsx +++ b/src/components/Comment.jsx @@ -1,6 +1,6 @@ import '../types/OffsidesTypes.js'; import React from 'react'; -import { View, Alert, Linking } from 'react-native'; +import { View, Alert, Linking, Pressable } from 'react-native'; import { Badge, Button, Card, Chip, IconButton, Text, useTheme } from 'react-native-paper'; import { setStringAsync as copyToClipboard } from 'expo-clipboard'; import timesago from 'timesago'; @@ -38,6 +38,20 @@ function Comment({ comment, nav, isolated = false }) { }); }; + const canOpenProfile = + !!nav && + !!comment?.identity?.posted_with_username && + !!comment?.identity?.name && + comment.identity.name != 'Anonymous'; + const openProfile = () => { + if (!canOpenProfile) return; + if (comment.authored_by_user) { + nav.push('MyProfile'); + } else { + nav.push('UserProfile', { username: comment.identity.name }); + } + }; + const deleteComment = () => { Alert.alert('Are you sure?', 'This will permanently delete this comment.', [ { @@ -68,7 +82,11 @@ function Comment({ comment, nav, isolated = false }) { mode="contained"> - + YOU )} - + + onPress={canOpenProfile ? openProfile : undefined} + style={{ + marginLeft: 10, + opacity: 0.75, + color: canOpenProfile ? colors.primary : undefined, + }}> @{comment.identity.name} )} diff --git a/src/components/Post.jsx b/src/components/Post.jsx index f0295e9..2d9f2a0 100644 --- a/src/components/Post.jsx +++ b/src/components/Post.jsx @@ -1,6 +1,6 @@ import { SidechatPostOrComment } from 'sidechat.js/src/types/SidechatTypes.js'; import React, { useState } from 'react'; -import { Alert, Linking, View } from 'react-native'; +import { Alert, Linking, Pressable, View } from 'react-native'; import { Card, Chip, IconButton, Text, useTheme } from 'react-native-paper'; import { setStringAsync as copyToClipboard } from 'expo-clipboard'; import timesago from 'timesago'; @@ -26,6 +26,7 @@ function Post({ cardMode = repost ? 'outlined' : 'elevated', apiInstance = null, themeColors = {}, + profileLink = true, }) { const colors = themeColors; const API = apiInstance; @@ -39,6 +40,21 @@ function Post({ const [identity, setIdentity] = useRecyclingState(post?.identity, [post]); const postID = post.id; + // Posts made with a username can be tapped through to that user's public profile. + const hasUsername = + !!identity?.name && + identity.name != 'Anonymous' && + identity.posted_with_username !== false; + const canOpenProfile = profileLink && hasUsername && !!nav; + const openProfile = React.useCallback(() => { + if (!canOpenProfile) return; + if (post.authored_by_user) { + nav.push('MyProfile'); + } else { + nav.push('UserProfile', { username: identity.name }); + } + }, [canOpenProfile, post.authored_by_user, identity?.name, nav]); + const upvote = React.useCallback(() => { const action = vote == 'upvote' ? 'none' : 'upvote'; API.setVote(postID, action).then(res => { @@ -100,12 +116,14 @@ function Post({ style={repost ? { marginBottom: 10 } : {}}> - + + + + onPress={canOpenProfile ? openProfile : undefined} + style={{ + marginLeft: 10, + opacity: 0.75, + color: canOpenProfile ? colors.primary : undefined, + }}> @{post.identity.name} )} diff --git a/src/screens/EditProfileScreen.jsx b/src/screens/EditProfileScreen.jsx index 3345a29..19793a0 100644 --- a/src/screens/EditProfileScreen.jsx +++ b/src/screens/EditProfileScreen.jsx @@ -32,6 +32,8 @@ function EditProfileScreen({ navigation }) { secondary: null, }); const [username, setUsername] = React.useState(''); + const [bio, setBio] = React.useState(''); + const [initialBio, setInitialBio] = React.useState(''); const [usernameError, setUsernameError] = React.useState(false); const [error, setError] = React.useState(false); const [emoji, setEmoji] = React.useState(); @@ -64,11 +66,33 @@ function EditProfileScreen({ navigation }) { if (u.user?.username) { setUsername(u.user.username); } + // The bio lives on the public profile object; fall back to any field on the user object. + let currentBio = ''; + if (typeof u.user?.bio === 'string') currentBio = u.user.bio; + else if (typeof u.user?.description === 'string') currentBio = u.user.description; + else if (u.user?.username) { + try { + const p = await API.getUserProfile(u.user.username); + if (typeof p?.description === 'string') currentBio = p.description; + } catch (e) { + // no public profile yet; leave the bio empty + } + } + setBio(currentBio); + setInitialBio(currentBio); setLoading(false); }; const saveIcon = async () => { const uname = await API.setUsername(appState.userID, username); await uname; + if (bio.trim() !== initialBio.trim()) { + const bioRes = await API.setUserBio(appState.userID, bio.trim()); + if (bioRes?.message) { + setError(true); + loadCurrent(); + return; + } + } const res = await API.setUserIcon( appState.userID, emoji, @@ -113,6 +137,19 @@ function EditProfileScreen({ navigation }) { You can't set that as your username. + + + {bio.length}/200 + )} setError(false)}> - Sorry, you can't set that as your icon. + Sorry, that couldn't be saved. ); diff --git a/src/screens/MyProfileScreen.jsx b/src/screens/MyProfileScreen.jsx index 3336491..3cb4669 100644 --- a/src/screens/MyProfileScreen.jsx +++ b/src/screens/MyProfileScreen.jsx @@ -48,11 +48,11 @@ function MyProfileScreen({ navigation }) { header: "Comment Karma", value: karmaObj?.comment || 0 }); - karmaObj?.groups.forEach(group => { - const g = groupList.find((item => item.id == group.group_id)); + (karmaObj?.groups || []).forEach(group => { + const g = (groupList || []).find((item => item.id == group.group_id)); karmaObjects.push({ - header: g.name, - value: group.post + group.comment + header: g?.name || 'Group', + value: (group.post || 0) + (group.comment || 0) }) }); return karmaObjects; @@ -67,6 +67,13 @@ function MyProfileScreen({ navigation }) { crashlytics().log('Fetching profile'); const u = await API.getUpdates(currentGroup?.id); crashlytics().log('Profile fetched successfully'); + // Pull the bio from the public profile if the user object doesn't carry one. + if (u?.user?.username && typeof u.user.bio !== 'string' && typeof u.user.description !== 'string') { + try { + const p = await API.getUserProfile(u.user.username); + if (typeof p?.description === 'string') u.user.bio = p.description; + } catch (e) { /* no public profile */ } + } setUpdates(u); setLoading(false); }; @@ -160,6 +167,12 @@ function MyProfileScreen({ navigation }) { + {(() => { + const b = typeof updates.user?.bio === 'string' ? updates.user.bio : typeof updates.user?.description === 'string' ? updates.user.description : ''; + return b.trim() ? ( + {b.trim()} + ) : null; + })()} {karmaInfo.map((item) => diff --git a/src/screens/UserProfileScreen.jsx b/src/screens/UserProfileScreen.jsx new file mode 100644 index 0000000..39ac368 --- /dev/null +++ b/src/screens/UserProfileScreen.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { View, StatusBar, FlatList } from 'react-native'; +import { + Appbar, + useTheme, + Text, + Avatar, + ProgressBar, + IconButton, +} from 'react-native-paper'; +import crashlytics from '@react-native-firebase/crashlytics'; +import { AppContext } from '../App'; +import Post from '../components/Post'; + +const BORDER_RADIUS = 15; + +/** + * Public profile of another user, looked up by username. + * Route params: { username: string } + */ +function UserProfileScreen({ navigation, route }) { + const username = route?.params?.username; + const { appState } = React.useContext(AppContext); + const API = appState.API; + const { colors } = useTheme(); + const [profile, setProfile] = React.useState(null); + const [posts, setPosts] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [unavailable, setUnavailable] = React.useState(false); + + React.useEffect(() => { + let cancelled = false; + const load = async () => { + crashlytics().log(`Loading UserProfileScreen for @${username}`); + const [p, ps] = await Promise.allSettled([ + API.getUserProfile(username), + API.getUserPosts(username), + ]); + if (cancelled) return; + if (p.status === 'fulfilled' && p.value && typeof p.value === 'object') { + setProfile(p.value); + } else { + setUnavailable(true); + } + if (ps.status === 'fulfilled' && Array.isArray(ps.value)) { + setPosts(ps.value.filter(i => i?.id)); + } else { + setPosts([]); + } + setLoading(false); + }; + if (username) { + load(); + } else { + setUnavailable(true); + setLoading(false); + } + return () => { + cancelled = true; + }; + }, [username]); + + const icon = profile?.conversation_icon; + const bio = + typeof profile?.description === 'string' && profile.description.trim() + ? profile.description.trim() + : typeof profile?.bio === 'string' && profile.bio.trim() + ? profile.bio.trim() + : null; + + const Header = ( + + + {icon?.emoji ? ( + + ) : ( + + )} + + @{profile?.name || username} + {posts && ( + + {posts.length} {posts.length === 1 ? 'post' : 'posts'} + + )} + + + {bio && ( + + {bio} + + )} + {unavailable && !profile && ( + + This profile isn't available. The user may have changed their + username or made their profile private. + + )} + + ); + + const Empty = !loading ? ( + + + + No public posts + + + ) : null; + + return ( + + + + navigation.goBack()} /> + + + {loading && } + item.id} + ListHeaderComponent={Header} + ListEmptyComponent={Empty} + contentContainerStyle={{ paddingBottom: 20 }} + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + + + + )} + ListHeaderComponentStyle={{ marginBottom: 10 }} + windowSize={10} + /> + + ); +} + +export default UserProfileScreen;