mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
feat(cloud): PARITY-FOLLOW phase 2: Follow, followers and the Following section (#665)
* wip(cloud): PARITY-FOLLOW design fixtures (top-bar followers, sidebar Team and Following) * feat(cloud): PARITY-FOLLOW phase 1: sidebar Team list and the top bar's people (creator and who is here now) * test(cloud): PARITY-FOLLOW fixtures name the workspace a teammate is in * fix(cloud): PARITY-FOLLOW fixture typecheck * feat(cloud): PARITY-FOLLOW follow endpoint: workspace_follows (0055), PUT/DELETE /v1/team/workspaces/:id/follow, followers in workspace views * fix(cloud): PARITY-FOLLOW lint: the hover action's wrapper owns its visibility; no Button restyles; no side effect in a state updater * feat(cloud): PARITY-FOLLOW phase 2: Follow in the top bar, followers in the stack, Following section in the sidebar * test(cloud): PARITY-FOLLOW no real-time wait in the live-read test
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
CloudWorkspaceListResponseSchema,
|
||||
CloudWorkspacePresenceResponseSchema,
|
||||
CloudWorkspaceResponseSchema,
|
||||
CloudWorkspaceFollowSchema,
|
||||
OrgPromptsSchema,
|
||||
type CloudWorkspaceLinkTarget,
|
||||
type CloudWorkspaceListQuery,
|
||||
@@ -157,6 +158,9 @@ export function createCloudWorkspacesClient(options: CloudWorkspacesClientOption
|
||||
async patch(id: string, patch: CloudWorkspacePatch) {
|
||||
return call('PATCH', ws(id), CloudWorkspaceResponseSchema.extend({}).passthrough(), patch)
|
||||
},
|
||||
async follow(id: string, follow: boolean) {
|
||||
return call(follow ? 'PUT' : 'DELETE', `${ws(id)}/follow`, CloudWorkspaceFollowSchema)
|
||||
},
|
||||
async noteSent(id: string) {
|
||||
await call('POST', `${ws(id)}/sent`, NoteSentSchema, {})
|
||||
},
|
||||
|
||||
@@ -213,6 +213,7 @@ export function createCloudWorkspacesService(deps: {
|
||||
return reader.read(id)
|
||||
}),
|
||||
get: (id: string) => wrap(() => client.get(id)),
|
||||
follow: (id: string, follow: boolean) => wrap(() => client.follow(id, follow)),
|
||||
patch: (id: string, patch: CloudWorkspacePatch) =>
|
||||
wrap(async () => (await client.patch(id, patch)).workspace),
|
||||
resolveLink: (target: CloudWorkspaceLinkTarget, chatId?: string | null) =>
|
||||
|
||||
@@ -193,6 +193,15 @@ export function registerCloudWorkspacesHandlers(
|
||||
? s.patch(parsed.data.id, parsed.data.patch)
|
||||
: INVALID
|
||||
})
|
||||
handle('cloudWorkspaces:follow', async (args) => {
|
||||
const parsed = z.object({ id: Id, follow: z.boolean() }).safeParse(args)
|
||||
const s = await getService()
|
||||
return !s
|
||||
? NOT_CONFIGURED
|
||||
: parsed.success
|
||||
? s.follow(parsed.data.id, parsed.data.follow)
|
||||
: INVALID
|
||||
})
|
||||
handle('cloudWorkspaces:resolveLink', async (args) => {
|
||||
const parsed = z
|
||||
.object({ target: LinkTarget, chatId: ChatId.nullable().optional() })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
CloudWorkspace,
|
||||
CloudWorkspaceFollow,
|
||||
CloudWorkspaceGrant,
|
||||
CloudOrgSlug,
|
||||
CloudWorkspaceLinkTarget,
|
||||
@@ -37,6 +38,8 @@ export type CloudWorkspacesApi = {
|
||||
list: (query: CloudWorkspaceListQuery) => Promise<CloudWorkspaceListResult>
|
||||
get: (id: string) => Promise<CloudWorkspacesResult<CloudWorkspace>>
|
||||
patch: (id: string, patch: CloudWorkspacePatch) => Promise<CloudWorkspacesResult<CloudWorkspace>>
|
||||
/** Follow or unfollow a team workspace (PARITY-FOLLOW): the caller's state and its followers. */
|
||||
follow: (id: string, follow: boolean) => Promise<CloudWorkspacesResult<CloudWorkspaceFollow>>
|
||||
/** Resolves either link form; a slug link for another org fails with reason `other_org` and its `orgId`. */
|
||||
resolveLink: (
|
||||
target: CloudWorkspaceLinkTarget,
|
||||
|
||||
@@ -6,6 +6,7 @@ export const cloudWorkspacesApi = {
|
||||
list: (query) => ipcRenderer.invoke('cloudWorkspaces:list', query),
|
||||
get: (id) => ipcRenderer.invoke('cloudWorkspaces:get', { id }),
|
||||
patch: (id, patch) => ipcRenderer.invoke('cloudWorkspaces:patch', { id, patch }),
|
||||
follow: (id, follow) => ipcRenderer.invoke('cloudWorkspaces:follow', { id, follow }),
|
||||
resolveLink: (target, chatId) =>
|
||||
ipcRenderer.invoke('cloudWorkspaces:resolveLink', { target, chatId }),
|
||||
openGrant: (id) => ipcRenderer.invoke('cloudWorkspaces:openGrant', { id }),
|
||||
|
||||
@@ -1,26 +1,42 @@
|
||||
// PARITY-FOLLOW: the people on the cloud workspace in front, beside its W-123 chip. Phase 1 shows
|
||||
// its creator and who is here now (the team list's live presence); Follow arrives with the follow
|
||||
// endpoint. Nothing for a local workspace, a personal account, or a workspace only you are on.
|
||||
import React from 'react'
|
||||
// PARITY-FOLLOW: the people on the cloud workspace in front, beside its W-123 chip: its creator,
|
||||
// its followers and who is here now, then Follow. Nothing for a local workspace, a personal
|
||||
// account, or a workspace only you are on and nobody else could follow.
|
||||
import React, { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { useOrcaAccountKey } from '@/hooks/use-orca-account-key'
|
||||
import { openTeammateProfile } from '@/components/teammates/teammate-profile-store'
|
||||
import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire'
|
||||
import { sandboxIdForWorktree, useCloudWorkspaceIdentities } from './cloud-workspace-identity'
|
||||
import { CloudWorkspaceFollowers, workspaceFollowerEntries } from './CloudWorkspaceFollowers'
|
||||
import { useTeamWorkspacesLive } from './team-workspaces-live'
|
||||
import { setCloudWorkspaceFollow, useTeamWorkspacesLive } from './team-workspaces-live'
|
||||
import { useMemberDirectory } from './use-cloud-workspace-row-actions'
|
||||
import { useCloudWorkspacesAvailable } from './use-cloud-workspaces'
|
||||
|
||||
/**
|
||||
* Follow is offered on a teammate's team workspace; never on your own (it is yours already), and not
|
||||
* by a control plane that predates follows (`following` absent).
|
||||
*/
|
||||
export function followOffer(workspace: CloudWorkspace, selfId: string | null): boolean | null {
|
||||
if (workspace.following === undefined || workspace.createdBy === selfId) {
|
||||
return null
|
||||
}
|
||||
return workspace.following
|
||||
}
|
||||
|
||||
export function CloudWorkspacePeople({
|
||||
worktreeId
|
||||
}: {
|
||||
worktreeId: string
|
||||
}): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
const cloud = useAppStore((s) => s.orcaProfileAuthStatus?.cloud)
|
||||
const hostId = useAppStore((s) => s.activeWorkspaceExecutionHostId)
|
||||
const account = useOrcaAccountKey()
|
||||
const available = useCloudWorkspacesAvailable()
|
||||
const identities = useCloudWorkspaceIdentities()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const orgId = cloud?.activeOrgId ?? null
|
||||
const selfId = cloud?.userId ?? null
|
||||
const sandboxId = sandboxIdForWorktree({ id: worktreeId, hostId }, identities)
|
||||
@@ -30,21 +46,38 @@ export function CloudWorkspacePeople({
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
const following = followOffer(workspace, selfId)
|
||||
const entries = workspaceFollowerEntries({
|
||||
creatorId: workspace.createdBy,
|
||||
followers: [],
|
||||
followers: workspace.followers ?? [],
|
||||
presence: workspace.presence
|
||||
})
|
||||
// Only you, on your own workspace: nothing to show.
|
||||
if (entries.every((e) => e.userId === selfId)) {
|
||||
if (following === null && entries.every((e) => e.userId === selfId)) {
|
||||
return null
|
||||
}
|
||||
const toggle = (): void => {
|
||||
const next = !following
|
||||
setBusy(true)
|
||||
void setCloudWorkspaceFollow(workspace.id, next).then((ok) => {
|
||||
setBusy(false)
|
||||
if (!ok) {
|
||||
toast.error(
|
||||
next
|
||||
? t('cloudFollow.followFailed', 'Couldn’t follow this workspace')
|
||||
: t('cloudFollow.unfollowFailed', 'Couldn’t unfollow this workspace')
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
return (
|
||||
<CloudWorkspaceFollowers
|
||||
entries={entries}
|
||||
directory={directory}
|
||||
selfId={selfId}
|
||||
following={null}
|
||||
following={following}
|
||||
busy={busy}
|
||||
onToggleFollow={toggle}
|
||||
onOpenMember={(userId) => (userId === selfId ? undefined : openTeammateProfile(userId))}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2,17 +2,22 @@
|
||||
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useTeamWorkspacesLive } from './team-workspaces-live'
|
||||
import {
|
||||
setCloudWorkspaceFollow,
|
||||
setTeamWorkspacesLiveForTests,
|
||||
useTeamWorkspacesLive
|
||||
} from './team-workspaces-live'
|
||||
import { resetCloudWorkspacesAvailabilityForFixtures } from './use-cloud-workspaces'
|
||||
import { workspace } from './cloud-workspaces.test-fixture'
|
||||
|
||||
const list = vi.fn()
|
||||
const follow = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
list.mockReset()
|
||||
list.mockResolvedValue({ ok: true, value: [workspace({ title: 'Checkout' })] })
|
||||
Object.assign(window, {
|
||||
api: { cloudWorkspaces: { available: async () => true, list } }
|
||||
api: { cloudWorkspaces: { available: async () => true, list, follow } }
|
||||
})
|
||||
resetCloudWorkspacesAvailabilityForFixtures()
|
||||
})
|
||||
@@ -42,4 +47,14 @@ describe('useTeamWorkspacesLive (PARITY-FOLLOW)', () => {
|
||||
expect(view.getByTestId('a').textContent).toBe('none')
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a follow at once from the server answer, and leaves the list alone on a refusal', async () => {
|
||||
const w = workspace({ title: 'Checkout', following: false, followers: [] })
|
||||
setTeamWorkspacesLiveForTests('org:acme', [w])
|
||||
follow.mockResolvedValueOnce({ ok: true, value: { following: true, followers: ['user_ada'] } })
|
||||
expect(await setCloudWorkspaceFollow(w.id, true)).toBe(true)
|
||||
expect(follow).toHaveBeenCalledWith(w.id, true)
|
||||
follow.mockResolvedValueOnce({ ok: false, error: 'forbidden' })
|
||||
expect(await setCloudWorkspaceFollow(w.id, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,6 +87,32 @@ export function useTeamWorkspacesLive(account: string | null): readonly CloudWor
|
||||
)
|
||||
}
|
||||
|
||||
/** A local edit (a follow) shown at once, until the next read confirms it. */
|
||||
export function patchTeamWorkspaceLive(id: string, patch: Partial<CloudWorkspace>): void {
|
||||
if (snapshot) {
|
||||
publish({
|
||||
...snapshot,
|
||||
workspaces: snapshot.workspaces.map((w) => (w.id === id ? { ...w, ...patch } : w))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow or unfollow (PARITY-FOLLOW). The live list updates from the server's answer; a refusal
|
||||
* leaves it as it was and resolves false (the caller says so).
|
||||
*/
|
||||
export async function setCloudWorkspaceFollow(id: string, follow: boolean): Promise<boolean> {
|
||||
const result = await window.api.cloudWorkspaces.follow(id, follow).catch(() => null)
|
||||
if (!result?.ok) {
|
||||
return false
|
||||
}
|
||||
patchTeamWorkspaceLive(id, {
|
||||
following: result.value.following,
|
||||
followers: result.value.followers
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** Test and fixture seam: what the shared read last returned. */
|
||||
export function setTeamWorkspacesLiveForTests(
|
||||
account: string | null,
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
// PARITY-FOLLOW: the sidebar's Team list for a team account, under the nav and above Projects.
|
||||
// Who is on the team comes from the roster; what each teammate is doing now comes from the shared
|
||||
// live read of the team's workspaces. A personal account, or a team of one, shows nothing.
|
||||
// PARITY-FOLLOW: the sidebar's Team list and Following section for a team account, under the nav
|
||||
// and above Projects. Who is on the team comes from the roster; what each teammate is doing now, and
|
||||
// which workspaces this member follows, from the shared live read of the team's workspaces. A
|
||||
// personal account shows nothing; a team of one shows only what it follows.
|
||||
import React, { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useOrcaAccountKey } from '@/hooks/use-orca-account-key'
|
||||
import { useOrgRoster } from '@/components/billing/team/use-org-roster'
|
||||
import { openCloudWorkspacePicker } from '@/components/cloud-workspaces/cloud-workspace-picker-store'
|
||||
import { useTeamWorkspacesLive } from '@/components/cloud-workspaces/team-workspaces-live'
|
||||
import {
|
||||
setCloudWorkspaceFollow,
|
||||
useTeamWorkspacesLive
|
||||
} from '@/components/cloud-workspaces/team-workspaces-live'
|
||||
import { openCloudWorkspace } from '@/components/cloud-workspaces/open-cloud-workspace'
|
||||
import { useMemberDirectory } from '@/components/cloud-workspaces/use-cloud-workspace-row-actions'
|
||||
import { useCloudWorkspacesAvailable } from '@/components/cloud-workspaces/use-cloud-workspaces'
|
||||
import { openTeammateProfile } from '@/components/teammates/teammate-profile-store'
|
||||
import { SidebarTeamList } from './SidebarTeamSections'
|
||||
import { teammateActivities } from './team-activity'
|
||||
import { SidebarFollowingSection, SidebarTeamList } from './SidebarTeamSections'
|
||||
import { followedWorkspaces, teammateActivities } from './team-activity'
|
||||
|
||||
export function SidebarTeamPanel(): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
const cloud = useAppStore((s) => s.orcaProfileAuthStatus?.cloud)
|
||||
const account = useOrcaAccountKey()
|
||||
const available = useCloudWorkspacesAvailable()
|
||||
@@ -30,16 +38,39 @@ export function SidebarTeamPanel(): React.JSX.Element | null {
|
||||
[roster.result]
|
||||
)
|
||||
const members = useMemo(() => teammateActivities(memberIds, live), [memberIds, live])
|
||||
const followed = useMemo(() => followedWorkspaces(live, selfId), [live, selfId])
|
||||
if (!orgId || !available) {
|
||||
return null
|
||||
}
|
||||
const open = (sandboxId: string): void => {
|
||||
const workspace = live?.find((w) => w.id === sandboxId)
|
||||
if (workspace) {
|
||||
void openCloudWorkspace(workspace)
|
||||
}
|
||||
}
|
||||
const unfollow = (sandboxId: string): void => {
|
||||
void setCloudWorkspaceFollow(sandboxId, false).then((ok) => {
|
||||
if (!ok) {
|
||||
toast.error(t('cloudFollow.unfollowFailed', 'Couldn’t unfollow this workspace'))
|
||||
}
|
||||
})
|
||||
}
|
||||
return (
|
||||
<SidebarTeamList
|
||||
members={members}
|
||||
directory={directory}
|
||||
selfId={selfId}
|
||||
onOpenProfile={openTeammateProfile}
|
||||
onShowWorkspaces={(userId) => openCloudWorkspacePicker({ kind: 'teammate', userId })}
|
||||
/>
|
||||
<>
|
||||
<SidebarTeamList
|
||||
members={members}
|
||||
directory={directory}
|
||||
selfId={selfId}
|
||||
onOpenProfile={openTeammateProfile}
|
||||
onShowWorkspaces={(userId) => openCloudWorkspacePicker({ kind: 'teammate', userId })}
|
||||
/>
|
||||
<SidebarFollowingSection
|
||||
workspaces={followed}
|
||||
directory={directory}
|
||||
selfId={selfId}
|
||||
onOpen={open}
|
||||
onUnfollow={unfollow}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { CloudWorkspace } from '../../../../../shared/cloud-workspaces/cloud-workspaces-wire'
|
||||
import { formatWorkspaceNumber } from '../../../../../shared/cloud-workspaces/cloud-workspaces-wire'
|
||||
import { repoLabel } from '@/components/cloud-workspaces/cloud-workspace-format'
|
||||
import type { SidebarTeammate } from './SidebarTeamSections'
|
||||
import type { SidebarFollowedWorkspace, SidebarTeammate } from './SidebarTeamSections'
|
||||
|
||||
/** A workspace by name (people recognise names); its W-number, then its repo, as fallbacks. */
|
||||
export function workspaceDisplayLabel(
|
||||
@@ -47,3 +47,31 @@ export function teammateActivities(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function followedStatus(w: CloudWorkspace): SidebarFollowedWorkspace['status'] {
|
||||
if (w.archivedAt !== null) {
|
||||
return 'archived'
|
||||
}
|
||||
if (w.status === 'running') {
|
||||
return 'running'
|
||||
}
|
||||
return w.status === 'creating' || w.status === 'resuming' ? 'waking' : 'asleep'
|
||||
}
|
||||
|
||||
/** Teammates' workspaces this member follows, most recently active first (PARITY-FOLLOW). */
|
||||
export function followedWorkspaces(
|
||||
workspaces: readonly CloudWorkspace[] | null,
|
||||
selfId: string | null
|
||||
): SidebarFollowedWorkspace[] {
|
||||
return (workspaces ?? [])
|
||||
.filter((w) => w.following === true && w.createdBy !== selfId)
|
||||
.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
||||
.map((w) => ({
|
||||
sandboxId: w.id,
|
||||
title: workspaceDisplayLabel(w),
|
||||
numberLabel: w.number ? formatWorkspaceNumber(w.number) : null,
|
||||
creatorId: w.createdBy,
|
||||
status: followedStatus(w),
|
||||
hereNow: new Set(w.presence.map((p) => p.userId).filter((id) => id !== selfId)).size
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presence, SELF_ID, workspace } from '../../cloud-workspaces/cloud-workspaces.test-fixture'
|
||||
import { followOffer } from '../../cloud-workspaces/CloudWorkspacePeople'
|
||||
import { followedWorkspaces } from './team-activity'
|
||||
|
||||
describe('followedWorkspaces (PARITY-FOLLOW)', () => {
|
||||
it("lists teammates' workspaces you follow, most recently active first, with who's here", () => {
|
||||
const rows = followedWorkspaces(
|
||||
[
|
||||
workspace({ title: 'Old', createdBy: 'user_grace', following: true, lastActivityAt: 1 }),
|
||||
workspace({
|
||||
title: 'Checkout',
|
||||
number: 582,
|
||||
createdBy: 'user_linus',
|
||||
following: true,
|
||||
lastActivityAt: 9,
|
||||
presence: [presence('user_mae'), presence(SELF_ID), presence('user_mae', 'typing')]
|
||||
}),
|
||||
workspace({ title: 'Not followed', createdBy: 'user_grace', following: false }),
|
||||
workspace({ title: 'Mine', createdBy: SELF_ID, following: true }),
|
||||
workspace({
|
||||
title: 'Asleep',
|
||||
createdBy: 'user_grace',
|
||||
following: true,
|
||||
status: 'suspended',
|
||||
lastActivityAt: 0
|
||||
})
|
||||
],
|
||||
SELF_ID
|
||||
)
|
||||
expect(rows.map((r) => [r.title, r.status, r.hereNow, r.numberLabel])).toEqual([
|
||||
['Checkout', 'running', 1, 'W-582'],
|
||||
['Old', 'running', 0, expect.any(String)],
|
||||
['Asleep', 'asleep', 0, expect.any(String)]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('followOffer (PARITY-FOLLOW)', () => {
|
||||
it("offers Follow on a teammate's workspace, never on your own or from an older control plane", () => {
|
||||
expect(followOffer(workspace({ createdBy: 'user_grace', following: false }), SELF_ID)).toBe(
|
||||
false
|
||||
)
|
||||
expect(followOffer(workspace({ createdBy: 'user_grace', following: true }), SELF_ID)).toBe(true)
|
||||
expect(followOffer(workspace({ createdBy: SELF_ID, following: false }), SELF_ID)).toBeNull()
|
||||
expect(followOffer(workspace({ createdBy: 'user_grace' }), SELF_ID)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -29732,7 +29732,9 @@
|
||||
"following": "Following",
|
||||
"unfollow": "Unfollow",
|
||||
"follow": "Follow",
|
||||
"unfollowNamed": "Unfollow {{title}}"
|
||||
"unfollowNamed": "Unfollow {{title}}",
|
||||
"followFailed": "Couldn’t follow this workspace",
|
||||
"unfollowFailed": "Couldn’t unfollow this workspace"
|
||||
},
|
||||
"sidebarTeam": {
|
||||
"title": "Team",
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createWebCloudWorkspacesApi(): PreloadApi['cloudWorkspaces'] {
|
||||
list: () => Promise.resolve(NOT_CONFIGURED),
|
||||
get: () => Promise.resolve(NOT_CONFIGURED),
|
||||
patch: () => Promise.resolve(NOT_CONFIGURED),
|
||||
follow: () => Promise.resolve(NOT_CONFIGURED),
|
||||
resolveLink: () => Promise.resolve(NOT_CONFIGURED),
|
||||
openGrant: () => Promise.resolve(NOT_CONFIGURED),
|
||||
readSnapshot: () => Promise.resolve(NOT_CONFIGURED),
|
||||
|
||||
@@ -88,6 +88,12 @@ export const CloudWorkspaceListResponseSchema = z.object({
|
||||
routineRuns: CloudWorkspaceRoutineRunsSchema.optional().catch(undefined)
|
||||
})
|
||||
export const CloudWorkspaceResponseSchema = z.object({ workspace: CloudWorkspaceSchema })
|
||||
/** PUT/DELETE /v1/team/workspaces/:id/follow (PARITY-FOLLOW). */
|
||||
export const CloudWorkspaceFollowSchema = z.object({
|
||||
following: z.boolean(),
|
||||
followers: z.array(Id)
|
||||
})
|
||||
export type CloudWorkspaceFollow = z.infer<typeof CloudWorkspaceFollowSchema>
|
||||
export const CloudWorkspacePresenceResponseSchema = z.object({
|
||||
presence: z.array(CloudWorkspacePresenceSchema)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user