diff --git a/packages/app/features/top/GroupChannelsScreen.tsx b/packages/app/features/top/GroupChannelsScreen.tsx index af3324427c..6f2bc55af8 100644 --- a/packages/app/features/top/GroupChannelsScreen.tsx +++ b/packages/app/features/top/GroupChannelsScreen.tsx @@ -39,6 +39,9 @@ export function GroupChannelsScreenContent({ null ); const { group } = useGroupContext({ groupId: id, isFocused }); + const { data: unjoinedChannels } = store.useUnjoinedGroupChannels( + group?.id ?? '' + ); const pinnedItems = useMemo(() => { return pins ?? []; @@ -60,6 +63,20 @@ export function GroupChannelsScreenContent({ const [enableCustomChannels] = useFeatureFlag('customChannelCreation'); + const handleJoinChannel = useCallback( + async (channel: db.Channel) => { + try { + await store.joinGroupChannel({ + channelId: channel.id, + groupId: id, + }); + } catch (error) { + console.error('Failed to join channel:', error); + } + }, + [id] + ); + return ( { } ); }; + +export const joinChannel = async (channelId: string, groupId: string) => { + return trackedPoke( + { + app: 'channels', + mark: 'channel-action', + json: { + channel: { + nest: channelId, + action: { + join: groupId, + }, + }, + }, + }, + { app: 'channels', path: '/v1' }, + (event) => { + return 'join' in event.response && event.nest === channelId; + } + ); +}; diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 20e58a87ff..0717dffccc 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -222,6 +222,60 @@ export const getPendingChats = createReadQuery( ['groups', 'channels'] ); +export const getUnjoinedGroupChannels = createReadQuery( + 'getUnjoinedGroupChannels', + async (groupId: string, ctx: QueryCtx) => { + const currentUserId = getCurrentUserId(); + const allUnjoined = await ctx.db.query.channels.findMany({ + where: and( + eq($channels.groupId, groupId), + eq($channels.currentUserIsMember, false) + ), + with: { + readerRoles: true, + group: { + with: { + roles: { + with: { + members: true, + }, + }, + }, + }, + }, + }); + + const unjoinedIds = allUnjoined.map((c) => c.id); + const unjoinedIndex = new Map(); + for (const channel of allUnjoined) { + unjoinedIndex.set(channel.id, channel); + } + + const joinableSubset = unjoinedIds.filter((id) => { + const channel = unjoinedIndex.get(id); + const isOpenChannel = channel?.readerRoles?.length === 0; + + const userRolesForGroup = + channel?.group?.roles + ?.filter((role) => + role.members?.map((m) => m.contactId).includes(currentUserId) + ) + .map((role) => role.id) ?? []; + + const isClosedButCanRead = channel?.readerRoles + ?.map((r) => r.roleId) + .some((r) => userRolesForGroup.includes(r)); + + return isOpenChannel || isClosedButCanRead; + }); + + return joinableSubset + .map((id) => unjoinedIndex.get(id)) + .filter((c) => c !== undefined) as Channel[]; + }, + ['channels'] +); + export const getPins = createReadQuery( 'getPins', async (ctx: QueryCtx): Promise => { @@ -1711,17 +1765,29 @@ export const addJoinedGroupChannel = createWriteQuery( async ({ channelId }: { channelId: string }, ctx: QueryCtx) => { logger.log('addJoinedGroupChannel', channelId); - await ctx.db.insert($groupNavSectionChannels).values({ - channelId, - groupNavSectionId: 'default', - }); - - return await ctx.db + // First update the channel membership + await ctx.db .update($channels) .set({ currentUserIsMember: true, }) .where(eq($channels.id, channelId)); + + // Then check if channel exists in any section + const existingInAnySection = await ctx.db + .select() + .from($groupNavSectionChannels) + .where(eq($groupNavSectionChannels.channelId, channelId)); + + // Only add to default if it's not + if (existingInAnySection.length === 0) { + await ctx.db.insert($groupNavSectionChannels).values({ + channelId, + groupNavSectionId: 'default', + }); + } + + return; }, ['channels'] ); diff --git a/packages/shared/src/store/channelActions.ts b/packages/shared/src/store/channelActions.ts index 137b6b762e..0884e7be6a 100644 --- a/packages/shared/src/store/channelActions.ts +++ b/packages/shared/src/store/channelActions.ts @@ -323,8 +323,33 @@ export async function leaveGroupChannel(channelId: string) { try { await api.leaveChannel(channelId); } catch (e) { - console.error('Failed to leave chat channel', e); + console.error('Failed to leave channel', e); // rollback optimistic update await db.updateChannel({ id: channelId, currentUserIsMember: true }); } } + +export async function joinGroupChannel({ + channelId, + groupId, +}: { + channelId: string; + groupId: string; +}) { + // optimistic update + await db.updateChannel({ + id: channelId, + currentUserIsMember: true, + }); + + try { + await api.joinChannel(channelId, groupId); + } catch (e) { + // rollback on failure + logger.error('Failed to join group channel'); + await db.updateChannel({ + id: channelId, + currentUserIsMember: false, + }); + } +} diff --git a/packages/shared/src/store/dbHooks.ts b/packages/shared/src/store/dbHooks.ts index 3900984754..5eb0c9fa0c 100644 --- a/packages/shared/src/store/dbHooks.ts +++ b/packages/shared/src/store/dbHooks.ts @@ -82,6 +82,20 @@ export const usePendingChats = ( }); }; +export const useUnjoinedGroupChannels = (groupId: string) => { + const deps = useKeyFromQueryDeps(db.getUnjoinedGroupChannels); + return useQuery({ + queryKey: [['unjoinedChannels', groupId], deps], + queryFn: async () => { + if (!groupId) { + return []; + } + const unjoined = await db.getUnjoinedGroupChannels(groupId); + return unjoined; + }, + }); +}; + export const usePins = ( queryConfig?: CustomQueryConfig ): UseQueryResult => { diff --git a/packages/ui/src/components/Avatar.tsx b/packages/ui/src/components/Avatar.tsx index f02a0698ba..d437509bd4 100644 --- a/packages/ui/src/components/Avatar.tsx +++ b/packages/ui/src/components/Avatar.tsx @@ -129,15 +129,17 @@ export const GroupAvatar = React.memo(function GroupAvatarComponent({ export const ChannelAvatar = React.memo(function ChannelAvatarComponent({ model, useTypeIcon, + dimmed, ...props }: { model: db.Channel; useTypeIcon?: boolean; + dimmed?: boolean; } & AvatarProps) { const channelTitle = utils.useChannelTitle(model); if (useTypeIcon) { - return ; + return ; } else if (model.type === 'dm') { return ( ) { return ( ); diff --git a/packages/ui/src/components/ChannelNavSection.tsx b/packages/ui/src/components/ChannelNavSection.tsx index ce17aa804b..ddd036086a 100644 --- a/packages/ui/src/components/ChannelNavSection.tsx +++ b/packages/ui/src/components/ChannelNavSection.tsx @@ -1,6 +1,6 @@ import * as db from '@tloncorp/shared/db'; import { useCallback, useMemo } from 'react'; -import { SizableText, YStack } from 'tamagui'; +import { SizableText, YStack, getVariableValue, useTheme } from 'tamagui'; import { ChannelListItem } from './ListItem'; @@ -32,13 +32,15 @@ export default function ChannelNavSection({ [channels] ); + const listSectionTitleColor = getVariableValue(useTheme().secondaryText); + return ( {section.title} diff --git a/packages/ui/src/components/ChannelNavSections.tsx b/packages/ui/src/components/ChannelNavSections.tsx index 5e69ed9063..0a3f7a68c9 100644 --- a/packages/ui/src/components/ChannelNavSections.tsx +++ b/packages/ui/src/components/ChannelNavSections.tsx @@ -1,6 +1,6 @@ import * as db from '@tloncorp/shared/db'; import { useMemo } from 'react'; -import { SizableText, YStack } from 'tamagui'; +import { SizableText, YStack, getVariableValue, useTheme } from 'tamagui'; import ChannelNavSection from './ChannelNavSection'; import { ChannelListItem } from './ListItem'; @@ -49,6 +49,8 @@ export default function ChannelNavSections({ [unGroupedChannels] ); + const listSectionTitleColor = getVariableValue(useTheme().secondaryText); + if (sortBy === 'recency') { return ( @@ -92,7 +94,7 @@ export default function ChannelNavSections({ paddingHorizontal="$l" paddingVertical="$xl" fontSize="$s" - color="$secondaryText" + color={listSectionTitleColor} > All Channels diff --git a/packages/ui/src/components/ChatOptionsSheet.tsx b/packages/ui/src/components/ChatOptionsSheet.tsx index 029105d094..8de60c3b64 100644 --- a/packages/ui/src/components/ChatOptionsSheet.tsx +++ b/packages/ui/src/components/ChatOptionsSheet.tsx @@ -798,7 +798,7 @@ export function ChannelOptions({ if (!isWeb) { Alert.alert( `Leave ${title}?`, - 'This will be removed from the list', + 'You will no longer receive updates from this channel.', [ { text: 'Cancel', diff --git a/packages/ui/src/components/GroupChannelsScreenView.tsx b/packages/ui/src/components/GroupChannelsScreenView.tsx index 819e13c604..596b91fbdc 100644 --- a/packages/ui/src/components/GroupChannelsScreenView.tsx +++ b/packages/ui/src/components/GroupChannelsScreenView.tsx @@ -1,26 +1,40 @@ import * as db from '@tloncorp/shared/db'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { ScrollView, View, YStack } from 'tamagui'; +import { + Button, + ScrollView, + View, + YStack, + getVariableValue, + useTheme, +} from 'tamagui'; import { useCurrentUserId } from '../contexts'; import { useIsAdmin } from '../utils/channelUtils'; +import { Badge } from './Badge'; import ChannelNavSections from './ChannelNavSections'; import { ChatOptionsSheet, ChatOptionsSheetMethods } from './ChatOptionsSheet'; +import { ChannelListItem } from './ListItem/ChannelListItem'; import { LoadingSpinner } from './LoadingSpinner'; import { CreateChannelSheet } from './ManageChannels/CreateChannelSheet'; import { ScreenHeader } from './ScreenHeader'; +import { Text } from './TextV2'; type GroupChannelsScreenViewProps = { group: db.Group | null; + unjoinedChannels?: db.Channel[]; onChannelPressed: (channel: db.Channel) => void; + onJoinChannel: (channel: db.Channel) => void; onBackPressed: () => void; enableCustomChannels?: boolean; }; export function GroupChannelsScreenView({ group, + unjoinedChannels = [], onChannelPressed, + onJoinChannel, onBackPressed, enableCustomChannels = false, }: GroupChannelsScreenViewProps) { @@ -65,6 +79,8 @@ export function GroupChannelsScreenView({ } }, [isGroupAdmin]); + const listSectionTitleColor = getVariableValue(useTheme().secondaryText); + return ( + + {unjoinedChannels.length > 0 && ( + + + Available Channels + + {unjoinedChannels.map((channel) => ( + onJoinChannel(channel)} + useTypeIcon={true} + dimmed={true} + EndContent={ + + + + } + /> + ))} + + )} ) : ( diff --git a/packages/ui/src/components/ListItem/ChannelListItem.tsx b/packages/ui/src/components/ListItem/ChannelListItem.tsx index db39db94ab..5700124d36 100644 --- a/packages/ui/src/components/ListItem/ChannelListItem.tsx +++ b/packages/ui/src/components/ListItem/ChannelListItem.tsx @@ -20,10 +20,12 @@ export function ChannelListItem({ onPress, onLongPress, EndContent, + dimmed, ...props }: { useTypeIcon?: boolean; customSubtitle?: string; + dimmed?: boolean; } & ListItemProps & { model: db.Channel }) { const unreadCount = model.unread?.count ?? 0; const title = utils.useChannelTitle(model); @@ -65,9 +67,13 @@ export function ChannelListItem({ onLongPress={handleLongPress} > - + - {title} + {title} {customSubtitle ? ( {customSubtitle} ) : ( diff --git a/packages/ui/src/components/ListItem/ListItem.tsx b/packages/ui/src/components/ListItem/ListItem.tsx index 09c0f101ba..50f87a1b6d 100644 --- a/packages/ui/src/components/ListItem/ListItem.tsx +++ b/packages/ui/src/components/ListItem/ListItem.tsx @@ -88,6 +88,13 @@ const ListItemTitle = styled(SizableText, { name: 'ListItemTitle', color: '$primaryText', numberOfLines: 1, + variants: { + dimmed: { + true: { + color: '$tertiaryText', + }, + }, + }, }); const ListItemSubtitleWithIcon = XStack.styleable<{ icon?: IconType }>(