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;