From 2dffc2378c5348524196801a6c06e16a7144190a Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Mon, 26 Aug 2024 14:48:54 -0500 Subject: [PATCH 01/10] native: add ability to invite users to groups --- .../src/components/AddGroupSheet.tsx | 36 +++++++++++- .../controllers/ChatListScreenController.tsx | 16 +++-- apps/tlon-mobile/tsconfig.json | 4 +- packages/app/features/top/ChatListScreen.tsx | 17 +++++- packages/shared/src/api/groupsApi.ts | 40 +++++++++++++ packages/shared/src/store/groupActions.ts | 39 +++++++++++++ .../ui/src/components/ChatOptionsSheet.tsx | 18 +++++- .../ui/src/components/InviteUsersSheet.tsx | 47 +++++++++++++++ .../ui/src/components/InviteUsersWidget.tsx | 58 +++++++++++++++++++ packages/ui/src/contexts/chatOptions.tsx | 8 +-- packages/ui/src/index.tsx | 2 + 11 files changed, 270 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/components/InviteUsersSheet.tsx create mode 100644 packages/ui/src/components/InviteUsersWidget.tsx diff --git a/apps/tlon-mobile/src/components/AddGroupSheet.tsx b/apps/tlon-mobile/src/components/AddGroupSheet.tsx index 2903b73ca4..692765bdc0 100644 --- a/apps/tlon-mobile/src/components/AddGroupSheet.tsx +++ b/apps/tlon-mobile/src/components/AddGroupSheet.tsx @@ -16,6 +16,7 @@ import { CreateGroupWidget, GroupPreviewPane, Icon, + InviteUsersWidget, Sheet, View, ViewUserGroupsWidget, @@ -55,6 +56,10 @@ type StackParamList = { Home: undefined; Root: undefined; CreateGroup: undefined; + InviteUsers: { + group: db.Group; + onInviteComplete: () => void; + }; ViewContactGroups: { contactId: string; }; @@ -138,6 +143,10 @@ export default function AddGroupSheet({ name="CreateGroup" component={CreateGroupScreen} /> + { - dismiss(); - onCreatedGroup(args); + props.navigation.push('InviteUsers', { + group: args.group, + onInviteComplete: () => { + dismiss(); + onCreatedGroup(args); + }, + }); }, - [dismiss, onCreatedGroup] + [dismiss, onCreatedGroup, props.navigation] ); return ( @@ -277,3 +291,19 @@ function CreateGroupScreen( ); } + +function InviteUsersScreen( + props: NativeStackScreenProps +) { + const { contacts } = useContext(ActionContext); + return ( + + + + + + ); +} diff --git a/apps/tlon-mobile/src/controllers/ChatListScreenController.tsx b/apps/tlon-mobile/src/controllers/ChatListScreenController.tsx index 985e92340c..337fbe9748 100644 --- a/apps/tlon-mobile/src/controllers/ChatListScreenController.tsx +++ b/apps/tlon-mobile/src/controllers/ChatListScreenController.tsx @@ -1,6 +1,8 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import ChatListScreen from '@tloncorp/app/features/top/ChatListScreen'; import * as db from '@tloncorp/shared/dist/db'; +import * as store from '@tloncorp/shared/dist/store'; +import { AppDataContextProvider } from 'packages/ui/src'; import { useCallback, useState } from 'react'; import AddGroupSheet from '../components/AddGroupSheet'; @@ -37,6 +39,8 @@ export function ChatListScreenController({ [goToChannel] ); + const { data: contacts } = store.useContacts(); + return ( <> - + + + ); } diff --git a/apps/tlon-mobile/tsconfig.json b/apps/tlon-mobile/tsconfig.json index 42150135ab..12bb3a3e07 100644 --- a/apps/tlon-mobile/tsconfig.json +++ b/apps/tlon-mobile/tsconfig.json @@ -7,7 +7,9 @@ "tamagui.config.ts", "tamagui.d.ts", "index.js", - "svg.d.ts" + "svg.d.ts", + "../../packages/ui/src/**/*.ts", + "../../packages/ui/src/**/*.tsx" ], "compilerOptions": { "composite": true diff --git a/packages/app/features/top/ChatListScreen.tsx b/packages/app/features/top/ChatListScreen.tsx index 7599087e1f..724d197ec2 100644 --- a/packages/app/features/top/ChatListScreen.tsx +++ b/packages/app/features/top/ChatListScreen.tsx @@ -12,6 +12,7 @@ import { FloatingAddButton, GroupPreviewSheet, Icon, + InviteUsersSheet, NavBarView, RequestsProvider, ScreenHeader, @@ -60,6 +61,7 @@ export default function ChatListScreen({ navigateToProfile: () => void; }) { const [screenTitle, setScreenTitle] = useState('Home'); + const [inviteSheetGroup, setInviteSheetGroup] = useState(); const chatOptionsSheetRef = useRef(null); const [longPressedChat, setLongPressedChat] = useState< db.Channel | db.Group | null @@ -119,7 +121,6 @@ export default function ChatListScreen({ [navigateToDm, setStartDmOpen] ); - const [isChannelSwitcherEnabled] = useFeatureFlag('channelSwitcher'); const onPressChat = useCallback( @@ -166,6 +167,11 @@ export default function ChatListScreen({ } }, []); + const handleInviteSheetOpenChange = useCallback((open: boolean) => { + if (!open) { + setInviteSheetGroup(null); + } + }, []); const { pinned: pinnedChats, unpinned } = resolvedChats; const allChats = [...pinnedChats, ...unpinned]; @@ -239,6 +245,9 @@ export default function ChatListScreen({ groupId={chatOptionsGroupId} pinned={pinned} {...useChatSettingsNavigation()} + onPressInvite={(group) => { + setInviteSheetGroup(group); + }} > + setInviteSheetGroup(null)} + group={inviteSheetGroup ?? undefined} + /> { diff --git a/packages/shared/src/api/groupsApi.ts b/packages/shared/src/api/groupsApi.ts index 6c01cff657..ddfb76bf1b 100644 --- a/packages/shared/src/api/groupsApi.ts +++ b/packages/shared/src/api/groupsApi.ts @@ -109,6 +109,46 @@ export function cancelGroupJoin(groupId: string) { }); } +export function inviteGroupMembers({ + groupId, + contactIds, +}: { + groupId: string; + contactIds: string[]; +}) { + return poke( + groupAction(groupId, { + cordon: { + shut: { + 'add-ships': { + ships: contactIds, + kind: 'pending', + }, + }, + }, + }) + ); +} + +export function addGroupMembers({ + groupId, + contactIds, +}: { + groupId: string; + contactIds: string[]; +}) { + return poke( + groupAction(groupId, { + fleet: { + ships: contactIds, + diff: { + add: null, + }, + }, + }) + ); +} + export function rescindGroupInvitationRequest(groupId: string) { logger.log('api rescinding', groupId); return poke({ diff --git a/packages/shared/src/store/groupActions.ts b/packages/shared/src/store/groupActions.ts index a71d9d8abd..dd1c1328ed 100644 --- a/packages/shared/src/store/groupActions.ts +++ b/packages/shared/src/store/groupActions.ts @@ -100,6 +100,45 @@ export async function rescindGroupInvitationRequest(group: db.Group) { } } +export async function inviteGroupMembers({ + groupId, + contactIds, +}: { + groupId: string; + contactIds: string[]; +}) { + logger.log('inviting group members', groupId, contactIds); + + const existingGroup = await db.getGroup({ id: groupId }); + + if (!existingGroup) { + console.error('Group not found', groupId); + return; + } + + // optimistic update + await db.addChatMembers({ + chatId: groupId, + type: 'group', + contactIds, + }); + + try { + if (existingGroup.privacy === 'public') { + await api.addGroupMembers({ groupId, contactIds }); + } else { + await api.inviteGroupMembers({ groupId, contactIds }); + } + } catch (e) { + console.error('Failed to invite group members', e); + // rollback optimistic update + await db.removeChatMembers({ + chatId: groupId, + contactIds, + }); + } +} + export async function cancelGroupJoin(group: db.Group) { logger.log('canceling group join', group.id); // optimistic update diff --git a/packages/ui/src/components/ChatOptionsSheet.tsx b/packages/ui/src/components/ChatOptionsSheet.tsx index c14b7fc44f..f251f47924 100644 --- a/packages/ui/src/components/ChatOptionsSheet.tsx +++ b/packages/ui/src/components/ChatOptionsSheet.tsx @@ -121,6 +121,7 @@ export function GroupOptions({ onPressGroupMembers, onPressGroupMeta, onPressManageChannels, + onPressInvite, onPressLeave, onTogglePinned, } = useChatOptions() ?? {}; @@ -258,11 +259,25 @@ export function GroupOptions({ endIcon: 'ChevronRight', }; + const inviteAction: Action = { + title: 'Invite people', + action: () => { + sheetRef.current.setOpen(false); + onPressInvite?.(group); + }, + endIcon: 'ChevronRight', + }; + actionGroups.push({ accent: 'neutral', actions: group && currentUserIsAdmin - ? [manageChannelsAction, goToMembersAction, metadataAction] + ? [ + manageChannelsAction, + goToMembersAction, + inviteAction, + metadataAction, + ] : [goToMembersAction], }); @@ -292,6 +307,7 @@ export function GroupOptions({ onPressGroupMembers, onPressGroupMeta, onPressLeave, + onPressInvite, ]); const memberCount = group?.members?.length ?? 0; diff --git a/packages/ui/src/components/InviteUsersSheet.tsx b/packages/ui/src/components/InviteUsersSheet.tsx new file mode 100644 index 0000000000..7fd182c0f3 --- /dev/null +++ b/packages/ui/src/components/InviteUsersSheet.tsx @@ -0,0 +1,47 @@ +import * as db from '@tloncorp/shared/dist/db'; +import React from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { InviteUsersWidget } from './InviteUsersWidget'; +import { Sheet } from './Sheet'; + +const InviteUsersSheetComponent = ({ + open, + onOpenChange, + group, + onInviteComplete, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group?: db.Group; + onInviteComplete: () => void; +}) => { + const { bottom } = useSafeAreaInsets(); + + if (!group) { + return null; + } + + return ( + + + + + + + + ); +}; + +export const InviteUsersSheet = React.memo(InviteUsersSheetComponent); diff --git a/packages/ui/src/components/InviteUsersWidget.tsx b/packages/ui/src/components/InviteUsersWidget.tsx new file mode 100644 index 0000000000..d77480c427 --- /dev/null +++ b/packages/ui/src/components/InviteUsersWidget.tsx @@ -0,0 +1,58 @@ +import * as db from '@tloncorp/shared/dist/db'; +import * as store from '@tloncorp/shared/dist/store'; +import React, { useCallback, useState } from 'react'; +import { YStack } from 'tamagui'; + +import { Button } from './Button'; +import { ContactBook } from './ContactBook'; + +const InviteUsersWidgetComponent = ({ + group, + onInviteComplete, +}: { + group: db.Group; + onInviteComplete: () => void; +}) => { + const [invitees, setInvitees] = useState([]); + + const handleInviteButtonPress = useCallback(async () => { + if (invitees.length === 0) { + console.log('invite friends outside'); + return; + } + + await store.inviteGroupMembers({ + groupId: group.id, + contactIds: invitees, + }); + + onInviteComplete(); + }, [invitees, group.id, onInviteComplete]); + + const handleSkipButtonPress = useCallback(() => { + onInviteComplete(); + }, [onInviteComplete]); + + return ( + + + + + + ); +}; + +export const InviteUsersWidget = React.memo(InviteUsersWidgetComponent); diff --git a/packages/ui/src/contexts/chatOptions.tsx b/packages/ui/src/contexts/chatOptions.tsx index 9cd05ce03a..89b7b78e56 100644 --- a/packages/ui/src/contexts/chatOptions.tsx +++ b/packages/ui/src/contexts/chatOptions.tsx @@ -16,7 +16,7 @@ export type ChatOptionsContextValue = { onPressGroupMeta: (groupId: string) => void; onPressGroupMembers: (groupId: string) => void; onPressManageChannels: (groupId: string) => void; - onPressInvitesAndPrivacy: (groupId: string) => void; + onPressInvite?: (group: db.Group) => void; onPressRoles: (groupId: string) => void; onPressChannelMembers: (channelId: string) => void; onPressChannelMeta: (channelId: string) => void; @@ -39,7 +39,7 @@ type ChatOptionsProviderProps = { onPressGroupMeta: (groupId: string) => void; onPressGroupMembers: (groupId: string) => void; onPressManageChannels: (groupId: string) => void; - onPressInvitesAndPrivacy: (groupId: string) => void; + onPressInvite?: (group: db.Group) => void; onPressChannelMembers: (channelId: string) => void; onPressChannelMeta: (channelId: string) => void; onPressRoles: (groupId: string) => void; @@ -53,7 +53,7 @@ export const ChatOptionsProvider = ({ onPressGroupMeta, onPressGroupMembers, onPressManageChannels, - onPressInvitesAndPrivacy, + onPressInvite, onPressChannelMembers, onPressChannelMeta, onPressRoles, @@ -85,7 +85,7 @@ export const ChatOptionsProvider = ({ onPressGroupMeta, onPressGroupMembers, onPressManageChannels, - onPressInvitesAndPrivacy, + onPressInvite, onPressRoles, onPressLeave, onTogglePinned, diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index 3432e47408..b0b5d8f918 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -6,6 +6,8 @@ export * from './components/AppSetting'; export * from './components/Avatar'; export * from './components/BigInput'; export * from './components/BlockedContactsWidget'; +export * from './components/InviteUsersWidget'; +export * from './components/InviteUsersSheet'; export * from './components/Button'; export * from './components/Buttons'; export * from './components/Channel'; From 7dea4b5c9e81ab6dab7519f0ae693057fa83acbe Mon Sep 17 00:00:00 2001 From: Patrick O'Sullivan Date: Wed, 28 Aug 2024 15:22:41 -0500 Subject: [PATCH 02/10] Get lure links working in invite widget --- .../src/components/AddGroupSheet.tsx | 1 + .../controllers/ChatListScreenController.tsx | 11 +- packages/app/features/top/ChatListScreen.tsx | 6 + packages/shared/src/api/urbit.ts | 2 +- .../shared/src/logic/subscriptionTracking.ts | 36 ++ packages/shared/src/logic/utils.ts | 26 ++ packages/shared/src/store/index.ts | 1 + packages/shared/src/store/lure.ts | 430 ++++++++++++++++++ packages/shared/src/store/sync.ts | 2 + .../ui/src/components/ChatOptionsSheet.tsx | 4 +- .../ui/src/components/InviteUsersWidget.tsx | 69 ++- packages/ui/src/contexts/appDataContext.tsx | 36 +- 12 files changed, 613 insertions(+), 11 deletions(-) create mode 100644 packages/shared/src/logic/subscriptionTracking.ts create mode 100644 packages/shared/src/store/lure.ts diff --git a/apps/tlon-mobile/src/components/AddGroupSheet.tsx b/apps/tlon-mobile/src/components/AddGroupSheet.tsx index 692765bdc0..1f137560be 100644 --- a/apps/tlon-mobile/src/components/AddGroupSheet.tsx +++ b/apps/tlon-mobile/src/components/AddGroupSheet.tsx @@ -296,6 +296,7 @@ function InviteUsersScreen( props: NativeStackScreenProps ) { const { contacts } = useContext(ActionContext); + return ( diff --git a/apps/tlon-mobile/src/controllers/ChatListScreenController.tsx b/apps/tlon-mobile/src/controllers/ChatListScreenController.tsx index 337fbe9748..be7cde6e04 100644 --- a/apps/tlon-mobile/src/controllers/ChatListScreenController.tsx +++ b/apps/tlon-mobile/src/controllers/ChatListScreenController.tsx @@ -1,8 +1,9 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { BRANCH_DOMAIN, BRANCH_KEY } from '@tloncorp/app/constants'; import ChatListScreen from '@tloncorp/app/features/top/ChatListScreen'; import * as db from '@tloncorp/shared/dist/db'; import * as store from '@tloncorp/shared/dist/store'; -import { AppDataContextProvider } from 'packages/ui/src'; +import { AppDataContextProvider } from '@tloncorp/ui'; import { useCallback, useState } from 'react'; import AddGroupSheet from '../components/AddGroupSheet'; @@ -65,8 +66,14 @@ export function ChatListScreenController({ navigateToProfile={() => { navigation.navigate('Profile'); }} + branchDomain={BRANCH_DOMAIN} + branchKey={BRANCH_KEY} /> - + void; @@ -59,6 +61,8 @@ export default function ChatListScreen({ navigateToHome: () => void; navigateToNotifications: () => void; navigateToProfile: () => void; + branchDomain: string; + branchKey: string; }) { const [screenTitle, setScreenTitle] = useState('Home'); const [inviteSheetGroup, setInviteSheetGroup] = useState(); @@ -232,6 +236,8 @@ export default function ChatListScreen({ = {}; + + const getPreviewTracking = (k: string) => + tracked[k] || { + inProgress: false, + attempted: 0, + }; + + const isPastWaiting = (attempted: number) => Date.now() - attempted >= wait; + + return { + tracked, + shouldLoad: (k: string) => { + const { attempted, inProgress } = getPreviewTracking(k); + console.log('shouldLoad', k, attempted, inProgress); + return isPastWaiting(attempted) && !inProgress; + }, + newAttempt: (k: string) => { + tracked[k] = { + inProgress: true, + attempted: Date.now(), + }; + }, + finished: (k: string) => { + tracked[k].inProgress = false; + }, + }; +} diff --git a/packages/shared/src/logic/utils.ts b/packages/shared/src/logic/utils.ts index b54d588770..defd24003a 100644 --- a/packages/shared/src/logic/utils.ts +++ b/packages/shared/src/logic/utils.ts @@ -38,6 +38,32 @@ export function isValidUrl(str?: string): boolean { return str ? !!URL_REGEX.test(str) : false; } +export async function asyncWithDefault( + cb: () => Promise, + def: T +): Promise { + try { + return await cb(); + } catch (error) { + console.error(error); + return def; + } +} + +// for purging storage with version updates +export function clearStorageMigration() { + return {} as T; +} + +export function getFlagParts(flag: string) { + const parts = flag.split('/'); + + return { + ship: parts[0], + name: parts[1], + }; +} + export function getPrettyAppName(kind: 'chat' | 'diary' | 'heap') { switch (kind) { case 'chat': diff --git a/packages/shared/src/store/index.ts b/packages/shared/src/store/index.ts index b75cd6a0dc..0ef4960fd2 100644 --- a/packages/shared/src/store/index.ts +++ b/packages/shared/src/store/index.ts @@ -15,3 +15,4 @@ export * from './useActivityFetchers'; export * from './session'; export * from './contactActions'; export * from './errorReporting'; +export * from './lure'; diff --git a/packages/shared/src/store/lure.ts b/packages/shared/src/store/lure.ts new file mode 100644 index 0000000000..5e25c4ab17 --- /dev/null +++ b/packages/shared/src/store/lure.ts @@ -0,0 +1,430 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useQuery } from '@tanstack/react-query'; +import produce from 'immer'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import create from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { poke, scry, subscribeOnce } from '../api/urbit'; +import { createDevLogger } from '../debug'; +import { createDeepLink } from '../logic/branch'; +import { getPreviewTracker } from '../logic/subscriptionTracking'; +import { + asyncWithDefault, + clearStorageMigration, + getFlagParts, +} from '../logic/utils'; +import { GroupMeta } from '../urbit/groups'; + +interface LureMetadata { + tag: string; + fields: Record; +} + +const LURE_REQUEST_TIMEOUT = 10 * 1000; + +interface Lure { + fetched: boolean; + url: string; + deepLinkUrl?: string; + enabled?: boolean; + enableAcked?: boolean; + metadata?: LureMetadata; +} + +interface Bait { + ship: string; + url: string; +} + +type Lures = Record; + +interface LureState { + bait: Bait | null; + lures: Lures; + fetchLure: ( + flag: string, + branchDomain: string, + branchKey: string, + fetchIfData?: boolean + ) => Promise; + describe: ( + flag: string, + metadata: LureMetadata, + branchDomain: string, + branchKey: string + ) => Promise; + toggle: ( + flag: string, + metadata: GroupMeta, + branchDomain: string, + branchKey: string + ) => Promise; + start: () => Promise; +} + +const lureLogger = createDevLogger('lure', true); + +// const LURE_REQUEST_TIMEOUT = 10 * 1000; + +function groupsDescribe(meta: GroupMeta) { + return { + tag: 'groups-0', + fields: { ...meta }, // makes typescript happy + }; +} + +export const useLureState = create( + persist( + (set, get) => ({ + bait: null, + lures: {}, + describe: async (flag, metadata, branchDomain, branchKey) => { + const { name } = getFlagParts(flag); + await poke({ + app: 'reel', + mark: 'reel-describe', + json: { + token: name, + metadata, + }, + }); + + return get().fetchLure(flag, branchDomain, branchKey); + }, + toggle: async (flag, meta, branchDomain, branchKey) => { + const { name } = getFlagParts(flag); + const lure = get().lures[flag]; + const enabled = !lure?.enabled; + if (!enabled) { + lureLogger.log('not enabled, poking reel-undescribe', flag); + await poke({ + app: 'reel', + mark: 'reel-undescribe', + json: { + token: getFlagParts(flag).name, + }, + }); + } else { + get().describe(flag, groupsDescribe(meta), branchDomain, branchKey); + } + + set( + produce((draft: LureState) => { + draft.lures[flag] = { + ...lure, + enabled, + }; + }) + ); + + await poke({ + app: 'grouper', + mark: enabled ? 'grouper-enable' : 'grouper-disable', + json: name, + }); + + return get().fetchLure(flag, branchDomain, branchKey); + }, + start: async () => { + const bait = await scry({ + app: 'reel', + path: '/bait', + }); + + set( + produce((draft: LureState) => { + draft.bait = bait; + }) + ); + }, + fetchLure: async (flag, branchDomain, branchKey) => { + const { name } = getFlagParts(flag); + const prevLure = get().lures[flag]; + lureLogger.log('fetching', flag, 'prevLure', prevLure); + const [enabled, url, metadata, outstandingPoke] = await Promise.all([ + // enabled + asyncWithDefault(async () => { + lureLogger.log(performance.now(), 'fetching enabled', flag); + return subscribeOnce( + { + app: 'grouper', + path: `/group-enabled/${flag}`, + }, + LURE_REQUEST_TIMEOUT + ).then((en) => { + lureLogger.log(performance.now(), 'enabled fetched', flag); + + return en; + }); + }, prevLure?.enabled), + // url + asyncWithDefault(async () => { + lureLogger.log(performance.now(), 'fetching url', flag); + return subscribeOnce( + { + app: 'reel', + path: `/token-link/${flag}`, + }, + LURE_REQUEST_TIMEOUT + ).then((u) => { + lureLogger.log(performance.now(), 'url fetched', u, flag); + return u; + }); + }, prevLure?.url), + // metadata + asyncWithDefault( + async () => + scry({ + app: 'reel', + path: `/metadata/${name}`, + }), + prevLure?.metadata + ), + // outstandingPoke + asyncWithDefault( + async () => + scry({ + app: 'reel', + path: `/outstanding-poke/${flag}`, + }), + false + ), + ]); + + // const enabled = await subscribeOnce({ + // app: 'grouper', + // path: `/group-enabled/${flag}`, + // }); + // .then((en) => { + // lureLogger.log(performance.now(), 'enabled fetched', en, flag); + // return en; + // }); + + // lureLogger.log('enabled fetched', enabled, flag); + + // const url = ''; + // const url = await subscribeOnce({ + // app: 'reel', + // path: `/token-link/${flag}`, + // }); + // .then((u) => { + // lureLogger.log(performance.now(), 'url fetched', flag, 'url', u); + // if (!u) { + // return ''; + // } + // return u; + // }); + // lureLogger.log('url fetched', url, flag); + + // const metadata = await scry({ + // app: 'reel', + // path: `/metadata/${name}`, + // }); + // lureLogger.log('metadata fetched', metadata, flag); + // .then((m) => { + // lureLogger.log( + // performance.now(), + // 'metadata fetched', + // flag, + // 'meta', + // m + // ); + // return m; + // }); + + // const outstandingPoke = await scry({ + // app: 'reel', + // path: `/outstanding-poke/${flag}`, + // }); + // lureLogger.log('outstanding-poke fetched', flag, outstandingPoke); + // .then((op) => { + // lureLogger.log( + // performance.now(), + // 'outstanding-poke fetched', + // flag, + // 'op', + // op + // ); + // return op; + // }); + + lureLogger.log( + 'fetched', + flag, + enabled, + url, + metadata, + outstandingPoke + ); + + let deepLinkUrl: string | undefined; + lureLogger.log('enabled', enabled); + if (enabled && url) { + deepLinkUrl = await createDeepLink( + url, + 'lure', + flag, + branchDomain, + branchKey + ); + } + + set( + produce((draft: LureState) => { + draft.lures[flag] = { + fetched: true, + enabled, + enableAcked: !outstandingPoke, + url, + deepLinkUrl, + metadata, + }; + }) + ); + }, + }), + { + name: 'lure', + version: 1, + migrate: clearStorageMigration, + getStorage: () => AsyncStorage, + } + ) +); + +const selLure = (flag: string) => (s: LureState) => ({ + lure: s.lures[flag] || { fetched: false, url: '' }, + bait: s.bait, +}); +const { shouldLoad, newAttempt, finished } = getPreviewTracker(30 * 1000); +export function useLure({ + flag, + branchDomain, + branchKey, + disableLoading = false, +}: { + flag: string; + branchDomain: string; + branchKey: string; + disableLoading?: boolean; +}) { + const { bait, lure } = useLureState(selLure(flag)); + + lureLogger.log('bait', bait); + lureLogger.log('lure', lure); + + useEffect(() => { + if (!bait || disableLoading || !shouldLoad(flag)) { + lureLogger.log('skipping', flag, bait, disableLoading, !shouldLoad(flag)); + return; + } + + lureLogger.log('fetching', flag, branchDomain, branchKey); + + newAttempt(flag); + useLureState + .getState() + .fetchLure(flag, branchDomain, branchKey) + .finally(() => finished(flag)); + }, [bait, flag, branchDomain, branchKey, disableLoading]); + + const toggle = async (meta: GroupMeta) => { + lureLogger.log('toggling', flag, meta, branchDomain, branchKey); + return useLureState.getState().toggle(flag, meta, branchDomain, branchKey); + }; + + const describe = useCallback( + (meta: GroupMeta) => { + return useLureState + .getState() + .describe(flag, groupsDescribe(meta), branchDomain, branchKey); + }, + [flag, branchDomain, branchKey] + ); + + lureLogger.log('useLure', flag, bait, lure, describe); + + return { + ...lure, + supported: bait, + describe, + toggle, + }; +} + +export function useLureLinkChecked(flag: string, enabled: boolean) { + const prevData = useRef(false); + const { data, ...query } = useQuery({ + queryKey: ['lure-check', flag], + queryFn: async () => + subscribeOnce( + { app: 'grouper', path: `/check-link/${flag}` }, + 4500 + ), + enabled, + refetchInterval: 5000, + }); + + prevData.current = data; + + lureLogger.log('useLureLinkChecked', flag, data); + + return { + ...query, + good: data, + checked: query.isFetched && !query.isLoading, + }; +} + +export function useLureLinkStatus({ + flag, + branchDomain, + branchKey, +}: { + flag: string; + branchDomain: string; + branchKey: string; +}) { + const { supported, fetched, enabled, enableAcked, url, deepLinkUrl, toggle } = + useLure({ + flag, + branchDomain, + branchKey, + }); + const { good, checked } = useLureLinkChecked(flag, !!enabled); + + lureLogger.log('useLureLinkStatus', { + flag, + supported, + fetched, + enabled, + checked, + good, + url, + deepLinkUrl, + }); + + const status = useMemo(() => { + if (!supported) { + return 'unsupported'; + } + + if (fetched && !enabled) { + return 'disabled'; + } + + if (!url || !fetched || !checked) { + lureLogger.log('loading', fetched, checked, url); + return 'loading'; + } + + if (checked && !good) { + return 'error'; + } + + return 'ready'; + }, [supported, fetched, enabled, url, good, checked]); + + return { status, shareUrl: deepLinkUrl ?? url, toggle }; +} diff --git a/packages/shared/src/store/sync.ts b/packages/shared/src/store/sync.ts index 58ac597e5e..7ef76d5633 100644 --- a/packages/shared/src/store/sync.ts +++ b/packages/shared/src/store/sync.ts @@ -12,6 +12,7 @@ import { resetActivityFetchers, } from '../store/useActivityFetchers'; import { ErrorReporter } from './errorReporting'; +import { useLureState } from './lure'; import { updateSession } from './session'; import { SyncCtx, SyncPriority, syncQueue } from './syncQueue'; import { addToChannelPosts, clearChannelPostsQueries } from './useChannelPosts'; @@ -51,6 +52,7 @@ export const syncInitData = async ( ); reporter?.log('got init data from api'); initializeJoinedSet(initData.unreads); + useLureState.getState().start(); const writer = async () => { await db diff --git a/packages/ui/src/components/ChatOptionsSheet.tsx b/packages/ui/src/components/ChatOptionsSheet.tsx index 799483e739..141ccd6b21 100644 --- a/packages/ui/src/components/ChatOptionsSheet.tsx +++ b/packages/ui/src/components/ChatOptionsSheet.tsx @@ -289,7 +289,9 @@ export function GroupOptions({ inviteAction, metadataAction, ] - : [goToMembersAction], + : group.privacy === 'public' || group.privacy === 'private' + ? [goToMembersAction, inviteAction] + : [goToMembersAction], }); if (group && !group.currentUserIsHost) { diff --git a/packages/ui/src/components/InviteUsersWidget.tsx b/packages/ui/src/components/InviteUsersWidget.tsx index d77480c427..b11c00404f 100644 --- a/packages/ui/src/components/InviteUsersWidget.tsx +++ b/packages/ui/src/components/InviteUsersWidget.tsx @@ -1,8 +1,11 @@ import * as db from '@tloncorp/shared/dist/db'; import * as store from '@tloncorp/shared/dist/store'; -import React, { useCallback, useState } from 'react'; -import { YStack } from 'tamagui'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Share } from 'react-native'; +import { YStack, isWeb } from 'tamagui'; +import { useBranchDomain, useBranchKey } from '../contexts'; +import { useCopy } from '../hooks/useCopy'; import { Button } from './Button'; import { ContactBook } from './ContactBook'; @@ -14,10 +17,35 @@ const InviteUsersWidgetComponent = ({ onInviteComplete: () => void; }) => { const [invitees, setInvitees] = useState([]); + const branchDomain = useBranchDomain(); + const branchKey = useBranchKey(); + const { status, shareUrl, toggle } = store.useLureLinkStatus({ + flag: group.id, + branchDomain: branchDomain, + branchKey: branchKey, + }); + const { doCopy } = useCopy(shareUrl); const handleInviteButtonPress = useCallback(async () => { - if (invitees.length === 0) { - console.log('invite friends outside'); + if (invitees.length === 0 && shareUrl && status === 'ready') { + if (isWeb) { + if (navigator.share !== undefined) { + await navigator.share({ + title: `Join ${group.title} on Tlon`, + url: shareUrl, + }); + return; + } + + doCopy(); + return; + } + + await Share.share({ + message: `Join ${group.title} on Tlon: ${shareUrl}`, + title: `Join ${group.title} on Tlon`, + }); + return; } @@ -27,7 +55,29 @@ const InviteUsersWidgetComponent = ({ }); onInviteComplete(); - }, [invitees, group.id, onInviteComplete]); + }, [ + invitees, + group.id, + onInviteComplete, + shareUrl, + group.title, + doCopy, + status, + ]); + + useEffect(() => { + const toggleLink = async () => { + await toggle({ + title: group.title ?? '', + description: group.description ?? '', + cover: group.coverImage ?? '', + image: group.iconImage ?? '', + }); + }; + if (status === 'disabled') { + toggleLink(); + } + }, [group, branchDomain, branchKey, toggle, status]); const handleSkipButtonPress = useCallback(() => { onInviteComplete(); @@ -41,7 +91,14 @@ const InviteUsersWidgetComponent = ({ searchPlaceholder="Filter by nickname, @p" onSelectedChange={setInvitees} /> -