Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): DMs list #1198

Open
wants to merge 5 commits into
base: mobile-app
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const DirectMessagesLayout = () => {
<Stack.Screen name='index'
options={{
title: 'Direct Messages',
headerLargeTitle: true
headerLargeTitle: false,
}} />
</Stack>
)
Expand Down
46 changes: 42 additions & 4 deletions apps/mobile/app/[site_id]/(tabs)/direct-messages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,48 @@
import { View } from 'react-native';
import { Link } from 'expo-router';
import { Pressable, ScrollView, View } from 'react-native';
import useGetDirectMessageChannels from '@raven/lib/hooks/useGetDirectMessageChannels';
import { DMChannelListItem } from '@raven/types/common/ChannelListItem';
import { useGetUser } from '@raven/lib/hooks/useGetUser';
import UserAvatar from '@components/layout/UserAvatar';
import { Text } from '@components/nativewindui/Text';
import { useColorScheme } from '@hooks/useColorScheme';
import { useIsUserActive } from '@hooks/useIsUserActive';

export default function DirectMessages() {
const { colors } = useColorScheme()

const { dmChannels } = useGetDirectMessageChannels()

return (
<ScrollView style={{ backgroundColor: colors.background }} className='px-2.5 py-1'>
{dmChannels.map((dm) => <DMRow key={dm.name} dm={dm} />)}
</ScrollView>
)
}

interface DMRowProps {
dm: DMChannelListItem
}
const DMRow = ({ dm }: DMRowProps) => {
const user = useGetUser(dm.peer_user_id)

const isActive = useIsUserActive(dm.peer_user_id)

return (
<View className="flex flex-1 items-center justify-center">
<Text className="text-2xl font-bold">DMs</Text>
</View>
<Link href={`../chat/${dm.name}`} asChild>
<Pressable
// Use tailwind classes for layout and ios:active state
className='flex-row items-center gap-3 px-3 py-2 rounded-lg ios:active:bg-linkColor'
// Add a subtle ripple effect on Android
android_ripple={{ color: 'rgba(0,0,0,0.1)', borderless: false }}
>
<UserAvatar src={user?.user_image} alt={user?.full_name ?? user?.name ?? ''} isActive={isActive} availabilityStatus={user?.availability_status} avatarProps={{ className: 'h-10 w-10' }} />

<View className='flex-col'>
<Text className='text-base dark:text-gray-300'>{user?.full_name}</Text>
<Text className='text-sm text-gray-600 dark:text-gray-500 mt-0.5'>{dm.last_message_details ? JSON.parse(dm?.last_message_details)?.content?.substring(0, 35) + "..." : ""}</Text>
</View>
</Pressable>
</Link>
)
}
54 changes: 54 additions & 0 deletions apps/mobile/components/nativewindui/useFetchActiveUsers.ts
pranavmene2000 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useFrappeEventListener, useFrappeGetCall, useSWRConfig } from 'frappe-react-sdk'
import { useActiveState } from './useActiveState'
import { useGetCurrentUser } from './useGetCurrentUser'

/**
* Hook to fetch active users from the server.
* SWRKey: active_users
*/
const useFetchActiveUsers = () => {
const res = useFrappeGetCall<{ message: string[] }>('raven.api.user_availability.get_active_users',
undefined,
'active_users',
{
dedupingInterval: 1000 * 60 * 5, // 5 minutes - do not refetch if the data is fresh
}
)

return res
}

/**
* Hook to listen to user_active_state_updated event and update the active_users list in realtime
* Also handles the user's active state via visibilty change and idle timer
*/
export const useFetchActiveUsersRealtime = () => {
const { currentUserInfo } = useGetCurrentUser();

const { mutate } = useSWRConfig()

useActiveState()

/** Hook to listen to user_active_state */
useFrappeEventListener('raven:user_active_state_updated', (data) => {
if (data.user !== currentUserInfo?.name) {
// If the user is not the current user, update the active_users list
// No need to revalidate the data as the websocket event has emitted the new data for that user
mutate('active_users', (res?: { message: string[] }) => {
if (res) {
if (data.active) {
return { message: [...res.message, data.user] }
} else {
return { message: res.message.filter(user => user !== data.user) }
}
} else {
return undefined
}
}, {
revalidate: false
})
}
})
}

export default useFetchActiveUsers
50 changes: 50 additions & 0 deletions apps/mobile/hooks/useActiveState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useContext, useEffect } from "react";
import { FrappeContext, FrappeConfig } from "frappe-react-sdk";
import { useActiveUser } from "@lib/UserInactivityProvider";
import { useBoolean } from "@raven/lib/hooks/useBoolean";

export type PresenceType = "active" | "idle";

interface Presence {
type: PresenceType;
}

export const useActiveState = () => {
const { call } = useContext(FrappeContext) as FrappeConfig;

const [isActive, { on: activate, off: deactivate }] = useBoolean(false);

const { isActive: isUserActive } = useActiveUser();

const updateUserActiveState = async (deactivate = false) => {
return call
.get("raven.api.user_availability.refresh_user_active_state", {
deactivate,
})
.catch(console.log);
};

const onPresenceChange = (presence: Presence) => {
if (presence.type === "active" && !isActive) {
updateUserActiveState().then(activate);
} else if (presence.type === "idle" && isActive) {
updateUserActiveState(true).then(deactivate);
}
};

useEffect(() => {
if (isUserActive) {
onPresenceChange({ type: "active" });
} else {
onPresenceChange({ type: "idle" });
}

return () => {
if (isActive) {
updateUserActiveState(true).then(deactivate);
}
};
}, [isUserActive, onPresenceChange]);

return isActive;
};
54 changes: 54 additions & 0 deletions apps/mobile/hooks/useFetchActiveUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import useCurrentRavenUser from '@raven/lib/hooks/useCurrentRavenUser'
import { useFrappeEventListener, useFrappeGetCall, useSWRConfig } from 'frappe-react-sdk'
import { useActiveState } from './useActiveState'

/**
* Hook to fetch active users from the server.
* SWRKey: active_users
*/
const useFetchActiveUsers = () => {
const res = useFrappeGetCall<{ message: string[] }>('raven.api.user_availability.get_active_users',
undefined,
'active_users',
{
dedupingInterval: 1000 * 60 * 5, // 5 minutes - do not refetch if the data is fresh
}
)

return res
}

/**
* Hook to listen to user_active_state_updated event and update the active_users list in realtime
* Also handles the user's active state via visibilty change and idle timer
*/
export const useFetchActiveUsersRealtime = () => {
const { myProfile: currentUserInfo } = useCurrentRavenUser();

const { mutate } = useSWRConfig()

useActiveState()

/** Hook to listen to user_active_state */
useFrappeEventListener('raven:user_active_state_updated', (data) => {
if (data.user !== currentUserInfo?.name) {
// If the user is not the current user, update the active_users list
// No need to revalidate the data as the websocket event has emitted the new data for that user
mutate('active_users', (res?: { message: string[] }) => {
if (res) {
if (data.active) {
return { message: [...res.message, data.user] }
} else {
return { message: res.message.filter(user => user !== data.user) }
}
} else {
return undefined
}
}, {
revalidate: false
})
}
})
}

export default useFetchActiveUsers
27 changes: 27 additions & 0 deletions apps/mobile/hooks/useIsUserActive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useMemo } from 'react';
import useFetchActiveUsers from './useFetchActiveUsers';
import useCurrentRavenUser from '@raven/lib/hooks/useCurrentRavenUser';
import { useGetUser } from '@raven/lib/hooks/useGetUser';

export const useIsUserActive = (userID?: string): boolean => {

const { myProfile: currentUserInfo } = useCurrentRavenUser()
const { data } = useFetchActiveUsers()

const user = useGetUser(userID)

const isActive = useMemo(() => {
// if user has explicitly set their status to invisible, do not show them as active
if (user?.availability_status === 'Invisible') {
return false
} else if (userID === currentUserInfo?.name) {
return true
} else if (userID) {
return data?.message.includes(userID) ?? false
} else {
return false
}
}, [userID, data])

return isActive
}
15 changes: 10 additions & 5 deletions apps/mobile/lib/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Text } from '@components/nativewindui/Text'
import { ChannelListContext, useChannelListProvider } from '@raven/lib/providers/ChannelListProvider'
import { UserListContext, useUserListProvider } from '@raven/lib/providers/UserListProvider'
import React, { PropsWithChildren } from 'react'
import { ActiveUserProvider } from './UserInactivityProvider'

const Providers = (props: PropsWithChildren) => {

Expand All @@ -16,11 +17,15 @@ const Providers = (props: PropsWithChildren) => {
return <Text>Error loading users</Text>
}

return <UserListContext.Provider value={{ users, enabledUsers }}>
<ChannelListProvider>
{props.children}
</ChannelListProvider>
</UserListContext.Provider>
return (
<ActiveUserProvider>
<UserListContext.Provider value={{ users, enabledUsers }}>
<ChannelListProvider>
{props.children}
</ChannelListProvider>
</UserListContext.Provider>
</ActiveUserProvider>
)
}

const ChannelListProvider = ({ children }: PropsWithChildren) => {
Expand Down
46 changes: 46 additions & 0 deletions apps/mobile/lib/UserInactivityProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React, { createContext, useContext, useState, ReactNode } from "react";
import UserInactivity from "react-native-user-inactivity";

interface ActiveUserContextType {
isActive: boolean;
}

const ActiveUserContext = createContext<ActiveUserContextType | undefined>(
undefined
);

interface ActiveUserProviderProps {
children: ReactNode;
inactivityTimeout?: number;
}

export const ActiveUserProvider = ({
children,
inactivityTimeout = 1000 * 60 * 10,
}: ActiveUserProviderProps) => {
const [isActive, setIsActive] = useState<boolean>(true);

const handleUserActivity = (active: boolean) => {
setIsActive(active);
};

return (
<ActiveUserContext.Provider value={{ isActive }}>
<UserInactivity
isActive={isActive}
timeForInactivity={inactivityTimeout}
onAction={handleUserActivity}
>
{children}
</UserInactivity>
</ActiveUserContext.Provider>
);
};

export const useActiveUser = (): ActiveUserContextType => {
const context = useContext(ActiveUserContext);
if (context === undefined) {
throw new Error("useActiveUser must be used within an ActiveUserProvider");
}
return context;
};
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"react-native-svg": "15.8.0",
"react-native-svg-transformer": "^1.5.0",
"react-native-uitextview": "^1.4.0",
"react-native-user-inactivity": "^1.2.0",
"react-native-web": "~0.19.13",
"react-native-webview": "13.12.5",
"react-native-zoom-toolkit": "^4.0.0",
Expand Down
19 changes: 19 additions & 0 deletions packages/lib/hooks/useBoolean.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useCallback, useState } from "react";

/**
* Simple hook to manage boolean (on - off) states
* @param initialState
* @returns
*/
export function useBoolean(initialState: boolean = false) {

const [value, setValue] = useState(initialState);

const on = useCallback(() => setValue(true), []);

const off = useCallback(() => setValue(false), []);

const toggle = useCallback(() => setValue(value => !value), []);

return [value, { on, off, toggle }, setValue] as const;
}
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8942,6 +8942,13 @@ react-native-uitextview@^1.4.0:
resolved "https://registry.yarnpkg.com/react-native-uitextview/-/react-native-uitextview-1.4.0.tgz#d1b583cc173cec00f4fdd03744cca76c54a12fbb"
integrity sha512-itm/frzkn/ma3+lwmKn2CkBOXPNo4bL8iVwQwjlzix5gVO59T2+axdfoj/Wi+Ra6F76KzNKxSah+7Y8dYmCHbQ==

react-native-user-inactivity@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/react-native-user-inactivity/-/react-native-user-inactivity-1.2.0.tgz#b0123951b89db9939eec07cffab724f7e76edcc6"
integrity sha512-VR+zv+cKBOSbJyHLJxvDUVluQ4OD/uW9FomAJVWrXCnSO4tYQieaAR3/5oNIfr6JXAJ/ChdJ/eyc06vHEBvlsA==
dependencies:
usetimeout-react-hook "^0.1.2"

react-native-web@~0.19.13:
version "0.19.13"
resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.19.13.tgz#2d84849bf0251ec0e3a8072fda7f9a7c29375331"
Expand Down Expand Up @@ -10556,6 +10563,11 @@ use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0:
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz#adbc795d8eeb47029963016cefdf89dc799fcebc"
integrity sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==

usetimeout-react-hook@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/usetimeout-react-hook/-/usetimeout-react-hook-0.1.2.tgz#fdeaff9b8fe2b5e5b8d29a3ef9c4054368711c03"
integrity sha512-uHc8QsWDznEhWkK+ygX0xWxyObRjy72685EZ5wr3/ipNsK0EYntGnbenKxLNirPKKsng+1KW5CPTAk7qQDY8VQ==

util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
Expand Down