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 }>(