diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index 130862e61..3f4a8eca7 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -2468,9 +2468,9 @@ export class ShareService extends PuterService { * asking the user to rebuild it. * * `updateMetadata` merges rather than replaces, and refreshes the cached - * row, so the switch bites on the very next share. Deliberately does not - * gate team-delivered shares; blocking the sender, or leaving the team, - * does. + * row, so the switch bites on the very next share. Shares made to a team + * are out of scope, as they are for `blockSender`: leaving the team is what + * ends those. */ async setBlockAllSenders( actor: Actor, @@ -2486,9 +2486,13 @@ export class ShareService extends PuterService { /** * Refuse further shares from `username`. Existing shares stand: access * someone already has is theirs until it is withdrawn, and a control - * labelled "block" silently revoking it would be a surprise. Team-delivered - * items from this sender stop being listed, announced or pushed while the - * block stands; the grants are untouched, so unblocking restores the view. + * labelled "block" silently revoking it would be a surprise. + * + * Shares made to a team both accounts belong to are deliberately out of + * scope: the grant is the team's, and one colleague does not get to + * withhold the team's files from another. Leaving the team ends those. The + * notification is still suppressed, so a block always stops the + * interruption even where it does not stop the access. */ async blockSender( actor: Actor, diff --git a/src/backend/services/share/TeamShare.test.ts b/src/backend/services/share/TeamShare.test.ts index 69e63ddf2..93f700c58 100644 --- a/src/backend/services/share/TeamShare.test.ts +++ b/src/backend/services/share/TeamShare.test.ts @@ -611,9 +611,9 @@ describe('sharing with a team', () => { }); // -- the recipient block list --------------------------------------- - // A block suspends delivery only — listing, count, fan-out, telling. The - // grant and the authority graph stay whole, or a reversible block turns - // into permanent revocations downstream. + // A team share is the team's, so a per-sender block does not withhold it + // from a colleague; only the notification is suppressed. Leaving the team + // is what ends the access. const block = (blocker: FixtureUser, blocked: FixtureUser) => fx.env.server.stores.userBlock.create(blocker.userId, blocked.userId); @@ -623,36 +623,30 @@ describe('sharing with a team', () => { blocked.userId, ); - it('does not deliver a team share to a member who blocked the sender', async () => { + it('still delivers a team share to a member who blocked the sender', async () => { const [blockingSeat, otherSeat] = fx.a.seats; await block(blockingSeat, fx.a.owner); try { - const before = await shares().listSharedWithMe( - await actorFor(blockingSeat.userId), - { limit: 100, includeTotal: true }, - ); const file = await makeFile(fx.a.owner.userId); await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid, }); - // Neither the listing entry nor the count moves for the blocker. - const after = await shares().listSharedWithMe( + // The block is about one person's contact, not the team's files. + const mine = await shares().listSharedWithMe( await actorFor(blockingSeat.userId), { limit: 100, includeTotal: true }, ); - expect(after.items.map((i) => i.entryUid)).not.toContain(file.uid); - expect(after.total).toBe(before.total); + expect(mine.items.map((i) => i.entryUid)).toContain(file.uid); - // The grant itself stands — nothing is revoked by a block. - const perms = + // And the grant is there to back it, not just the listing row. + expect( await fx.env.server.stores.permission.readUserGroupPerms( blockingSeat.userId, [`fs:${file.uid}:read`], - ); - expect(perms).toHaveLength(1); + ), + ).toHaveLength(1); - // The rest of the team is unaffected. const others = (await inbox(otherSeat.userId)).items; expect(others.map((i) => i.entryUid)).toContain(file.uid); } finally { @@ -660,31 +654,26 @@ describe('sharing with a team', () => { } }); - it('suspends delivery on block and restores it on unblock', async () => { + it('counts a team share for a blocking member, so page and total agree', async () => { const seat = fx.a.seats[0]; - const file = await makeFile(fx.a.owner.userId); - await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid }); - expect((await inbox(seat.userId)).items.map((i) => i.entryUid)).toContain( - file.uid, - ); - await block(seat, fx.a.owner); try { - expect( - (await inbox(seat.userId)).items.map((i) => i.entryUid), - ).not.toContain(file.uid); + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam(fx.a.owner.userId, file.path, { + team: fx.a.uid, + }); + const res = await shares().listSharedWithMe( + await actorFor(seat.userId), + { limit: 100, includeTotal: true }, + ); + expect(res.total).toBeGreaterThanOrEqual(res.items.length); } finally { await unblock(seat, fx.a.owner); } - - // Nothing was revoked, so lifting the block needs no re-share. - expect((await inbox(seat.userId)).items.map((i) => i.entryUid)).toContain( - file.uid, - ); }); - it('keeps a blocked member out of the live-event fan-out', async () => { - const [blockingSeat, otherSeat] = fx.a.seats; + it('keeps a blocking member in the live-event fan-out', async () => { + const [blockingSeat] = fx.a.seats; const file = await makeFile(fx.a.owner.userId); await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid }); const entry = await fx.env.server.stores.fsEntry.getEntryByUuid( @@ -693,66 +682,19 @@ describe('sharing with a team', () => { await block(blockingSeat, fx.a.owner); try { - // Pushing changes to them would tell them what the block hides. + // They can open the file, so they have to be told it changed. const rows = await fx.env.server.stores.share.listGroupReachingMembers([ entry.id, ]); - const reached = rows.map((r) => Number(r.holder_user_id)); - expect(reached).not.toContain(blockingSeat.userId); - expect(reached).toContain(otherSeat.userId); + expect(rows.map((r) => Number(r.holder_user_id))).toContain( + blockingSeat.userId, + ); } finally { await unblock(blockingSeat, fx.a.owner); } }); - it('only suspends the blocked pair, not the member\'s other shares', async () => { - const seat = fx.a.seats[0]; - const peerFile = await makeFile(fx.a.seats[1].userId); - await shareWithTeam(fx.a.seats[1].userId, peerFile.path, { - team: fx.a.uid, - }); - - await block(seat, fx.a.owner); - try { - const items = (await inbox(seat.userId)).items; - expect(items.map((i) => i.entryUid)).toContain(peerFile.uid); - } finally { - await unblock(seat, fx.a.owner); - } - }); - - it('leaves a blocked member their authority, so they can still withdraw', async () => { - const seat = fx.a.seats[0]; - const file = await makeFile(fx.a.owner.userId); - await shareWithTeam( - fx.a.owner.userId, - file.path, - { team: fx.a.uid }, - 'manage', - ); - await shares().share(await actorFor(seat.userId), { - path: file.path, - recipient: { username: fx.outsider.username }, - mode: 'read', - } as never); - - await block(seat, fx.a.owner); - try { - // Authority must survive the block, or what they granted becomes - // theirs to keep but not theirs to take back. - await shares().unshare(await actorFor(seat.userId), { - path: file.path, - recipient: { username: fx.outsider.username }, - } as never); - expect( - (await inbox(fx.outsider.userId)).items.map((i) => i.entryUid), - ).not.toContain(file.uid); - } finally { - await unblock(seat, fx.a.owner); - } - }); - // -- unshare sweeps by rows, not by a member page -------------------- it('sweeps a member re-share on team unshare', async () => { diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index 5939d7d07..04f1ec188 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -20,7 +20,6 @@ import { v4 as uuidv4 } from 'uuid'; import { HttpError } from '../../core/http/HttpError.js'; import { encodeCursor, decodeCursor } from '../../util/pagination'; -import { notBlockedSql } from '../userBlock/UserBlockStore'; import { PuterStore } from '../types'; /** Default page size for the keyset listings. */ @@ -89,16 +88,15 @@ export class ShareStore extends PuterStore { const afterId = this.#afterId(cursor); const groups = [...new Set(groupIds)].filter(Boolean); - // Same keyset page: `ORDER BY id` holds whatever the holder is. The - // group arm skips issuers this holder blocked. + // Same keyset page: `ORDER BY id` holds whatever the holder is. A + // per-sender block deliberately does not apply here — a team share is + // the team's, not one colleague's to withhold from another. const holderClause = groups.length - ? `(\`holder_user_id\` = ? OR (\`holder_group_id\` IN (${groups + ? `(\`holder_user_id\` = ? OR \`holder_group_id\` IN (${groups .map(() => '?') - .join(', ')}) AND ${this.#issuerNotBlockedSql()}))` + .join(', ')}))` : '`holder_user_id` = ?'; - const holderParams = groups.length - ? [holderUserId, ...groups, holderUserId] - : [holderUserId]; + const holderParams = [holderUserId, ...groups]; // One extra row tells us whether another page exists. const rows = await this.clients.db.read( @@ -391,7 +389,6 @@ export class ShareStore extends PuterStore { async listGroupReachingMembers(fsentryIds) { if (fsentryIds.length === 0) return []; const placeholders = fsentryIds.map(() => '?').join(', '); - // A member who blocked the issuer is not pushed that issuer's shares. const rows = await this.clients.db.read( 'SELECT `share`.*, `ug`.`user_id` AS `member_user_id` FROM `share` ' + 'JOIN `jct_user_group` `ug` ON `ug`.`group_id` = `share`.`holder_group_id` ' + @@ -399,7 +396,6 @@ export class ShareStore extends PuterStore { `WHERE \`share\`.\`fsentry_id\` IN (${placeholders}) ` + 'AND `share`.`holder_group_id` IS NOT NULL ' + 'AND `g`.`deleted_at` IS NULL ' + - `AND ${notBlockedSql('`ug`.`user_id`', '`share`.`issuer_user_id`')} ` + 'ORDER BY `share`.`id`', fsentryIds, ); @@ -464,17 +460,16 @@ export class ShareStore extends PuterStore { */ async countByHolder(holderUserId, { groupIds = [] } = {}) { const groups = [...new Set(groupIds)].filter(Boolean); - // Group arm filtered as `listByHolder` is, or the total overcounts. + // Same union as `listByHolder`, blocks included, or the total and the + // page disagree. const holderClause = groups.length - ? `(\`holder_user_id\` = ? OR (\`holder_group_id\` IN (${groups + ? `(\`holder_user_id\` = ? OR \`holder_group_id\` IN (${groups .map(() => '?') - .join(', ')}) AND ${this.#issuerNotBlockedSql()}))` + .join(', ')}))` : '`holder_user_id` = ?'; const rows = await this.clients.db.read( `SELECT COUNT(*) AS \`count\` FROM \`share\` WHERE ${holderClause}`, - groups.length - ? [holderUserId, ...groups, holderUserId] - : [holderUserId], + [holderUserId, ...groups], ); return Number(rows[0]?.count ?? 0); } @@ -1074,11 +1069,6 @@ export class ShareStore extends PuterStore { ); } - /** Group rows only exist for teams, so no kind guard is needed here. */ - #issuerNotBlockedSql() { - return notBlockedSql('?', '`share`.`issuer_user_id`'); - } - /** @param {number} [limit] */ #pageSize(limit) { return Math.min( diff --git a/src/backend/stores/userBlock/UserBlockStore.ts b/src/backend/stores/userBlock/UserBlockStore.ts index 7167c682f..5a296990c 100644 --- a/src/backend/stores/userBlock/UserBlockStore.ts +++ b/src/backend/stores/userBlock/UserBlockStore.ts @@ -19,19 +19,6 @@ import { PuterStore } from '../types'; -/** - * SQL fragment: true when `blockerExpr` has no block against `blockedExpr`. The - * one spelling of the rule, so every filtered surface stays in step. Exprs are - * SQL (a column or a `?`), never user input. - */ -export const notBlockedSql = ( - blockerExpr: string, - blockedExpr: string, -): string => - 'NOT EXISTS (SELECT 1 FROM `user_block` `ub` ' + - `WHERE \`ub\`.\`blocker_user_id\` = ${blockerExpr} ` + - `AND \`ub\`.\`blocked_user_id\` = ${blockedExpr})`; - /** One row of `user_block`. `created_at` is unix seconds. */ export interface UserBlockRow { id: number; diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md index a8a20739e..59f6d769f 100644 --- a/src/docs/src/FS/share.md +++ b/src/docs/src/FS/share.md @@ -37,7 +37,8 @@ Who to share with. A string containing `@` is treated as an email address, and a Where the deployment has [Teams](/Teams/), pass `{ team: uid }` to share with every member of a team the caller belongs to — including anyone added to it later. There is no string form for a team: a bare string is always read as an email or username. -A team share is never refused for one member's sake, so it does not produce `recipient_not_accepting_shares` — but a member who has blocked the sharer is not reached by it. Nothing you share with the team is listed, announced, or pushed to them while their block stands; the grant itself is untouched, so lifting the block restores their view without a re-share. The rest of the team is unaffected, and nothing tells the sharer. The blanket "block everyone" switch does not extend to teams the recipient belongs to — blocking the sender, or leaving the team, is what stops those. +A team share is never refused for one member's sake, so it does not produce `recipient_not_accepting_shares`, and **recipient blocks do not apply to it**: the grant is the team's, not one colleague's to withhold from another. A member who has blocked you still reaches anything you share with a team you both belong to — they are simply not notified about it. Leaving the team is what ends that access. + Pass `{ anyone: true }` to share with **anyone with the link** — see below. Only the object form is read as that; the word `anyone` typed as a string is a username like any other. #### `mode` (String) (optional) diff --git a/src/docs/src/Teams.md b/src/docs/src/Teams.md index 20d3c3e21..24f7531e7 100644 --- a/src/docs/src/Teams.md +++ b/src/docs/src/Teams.md @@ -6,115 +6,98 @@ platforms: [websites, apps]
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
-A team is a Puter account that pays for other accounts. Members are ordinary -Puter accounts — your app talks to them like any other user, and the team never -gains access to a member's files. +The Puter.js Teams feature lets your app see the team context around the user in front of it: whether they belong to one, and who their colleagues are. -Team *administration* — creating teams, provisioning accounts, suspending them — -happens in the account console, not through apps: those routes refuse app and -API-token callers outright. What `puter.teams` offers an app is the read-only -context around the user in front of it. +A team is a Puter account that pays for other accounts. Members are ordinary Puter accounts — your app talks to them like any other user, and the team never gains access to a member's files. Team *administration* (creating teams, provisioning accounts, suspending them) happens in the account console rather than through apps, so what `puter.teams` offers is read-only. -```js -const teams = await puter.teams.list(); -const colleagues = await puter.teams.listDirectory(teams[0]?.uid); +## Features + +**Team context.** `list()` tells you whether the signed-in user belongs to a team, and is also how you detect whether the deployment has Teams at all: it rejects with `not_found` where the feature is off, and resolves to an empty array where it is on and the user has no team. + +**Colleague lookup.** `listDirectory()` returns the team's members so your app can suggest people by name instead of asking users to type usernames. It is opt-in per team — until an owner opens the directory, it answers `team_not_found`, which is indistinguishable from having no team. + +**Sharing with a team.** Anything shared with a team reaches every member with one grant, including anyone added later. Pass the team's `uid` as the recipient of [`puter.fs.share()`](/FS/share/); there is no string form, since a bare string is always read as an email or username. + +**Stable identifiers.** A team has a `uid` and an optional `handle`. Only the `uid` is stable — a handle is a mutable label, and deleting the team releases it for anyone else to take. Display the `name` and `handle`; pass the `uid`. + +## Functions + +- **[`puter.teams.list()`](/Teams/list/)** - List the teams the signed-in user belongs to, and detect whether Teams is available at all +- **[`puter.teams.listDirectory()`](/Teams/listDirectory/)** - List a team's members, where the owner has opened the directory to apps + +Both are keyset-paginated and take the same options; see either method page for the paging forms and the full error list. + +## Examples + +Detect whether the user is on a team + +```html + + + + + + ``` -## Availability +Suggest a colleague to share with -Teams are an opt-in deployment feature. Where they are turned off, the routes -behind `puter.teams` do not exist and every method rejects with `not_found`. +```html + + + + + + ``` -## `uid`, not `handle` +Share a file with the whole team -A team has both a `uid` and an optional `handle`. Only the `uid` is stable: a -handle is a mutable label, and deleting the team releases it for anyone else to -take. Display the `name` and `handle`; pass the `uid`. - -## Methods - -| Method | Returns | -| -- | -- | -| [`list(options)`](/Teams/list/) | The caller's teams | -| [`listDirectory(uid, options)`](/Teams/listDirectory/) | The team's member directory, where the owner has opened it to apps | - -## Sharing with a team - -A team can receive a share like a person can — one grant reaches every member, -including anyone added later. Pass the team's `uid` as the recipient: - -```js -await puter.fs.share({ path, recipient: { team: team.uid }, mode: 'read' }); +```html + + + + + + ``` -A member who has blocked the sharer is the one exception: while the block -stands, nothing that sharer puts into the team is listed or announced to that -member, and the sharer is not told. The underlying grant is untouched, so -lifting the block restores the member's view. - -See [`puter.fs.share()`](/FS/share/) for the full sharing API. - -## Pagination - -`list()` and `listDirectory()` take the same options and offer the same three -forms: - -| Call | Resolves to | -| -- | -- | -| No options | The whole set as an array, fetched page by page under the hood | -| `{ cursor }` or `{ includeTotal: true }` | One `{ items, cursor? }` page. `cursor` is absent on the last page | -| `{ stream: true }` | An async iterator of `{ items, cursor? }` pages | - -`{ limit }` on its own still resolves to an array, capped at one page. - -These routes are keyset-paginated, so `offset` is not accepted — passing it -throws `invalid_request`. Pass `cursor` to resume from a position. - -## Objects - -#### `Team` - -| Field | Type | Description | -| -- | -- | -- | -| `uid` | `string` | The team's stable identifier. | -| `name` | `string \| null` | Its display name. | -| `handle` | `string \| null` | Its short handle, unique while it exists. | -| `isOwner` | `boolean` | Whether the caller is the owner account. | -| `createdAt` | `string` | When it was created. | - -#### `TeamDirectoryEntry` - -| Field | Type | Description | -| -- | -- | -- | -| `username` | `string` | A colleague's Puter username. | -| `uuid` | `string` | Their stable account identifier. | - -## Errors - -Every method rejects with an `Error` carrying a stable `code`: - -| Code | Meaning | -| -- | -- | -| `invalid_request` | The call was refused before reaching the server — a blank `uid`, an `offset` on a keyset list. | -| `unauthorized` | Not signed in. | -| `account_is_not_verified` | The caller's email has not been confirmed. | -| `not_found` | Teams are turned off on this deployment. | -| `team_not_found` | No such team, the caller is not a member of it, or its directory is not open to apps. | -| `too_many_requests` | The rate limit was exceeded. See [Rate Limits & Quotas](/rate-limits-and-quotas/). | - ## What is deliberately absent - **No sharing-policy controls.** A team cannot restrict who its members share diff --git a/src/docs/src/Teams/list.md b/src/docs/src/Teams/list.md index c050f98c1..fbdf7f22d 100644 --- a/src/docs/src/Teams/list.md +++ b/src/docs/src/Teams/list.md @@ -21,11 +21,42 @@ puter.teams.list(options) #### `options` (Object) (optional) -The standard list options — `limit`, `cursor`, `includeTotal` and `stream`. See [Pagination](/Teams/#pagination) for what each form returns. `offset` is not accepted. +The standard list options. All four are optional, and they decide the shape of what resolves: + +| Call | Resolves to | +| -- | -- | +| No options | The whole set as an array, fetched page by page under the hood | +| `{ limit }` | An array, capped at one page | +| `{ cursor }` or `{ includeTotal: true }` | One `{ items, cursor? }` page. `cursor` is absent on the last page | +| `{ stream: true }` | An async iterator of `{ items, cursor? }` pages | + +This route is keyset-paginated, so `offset` is not accepted — passing it throws `invalid_request`. Pass `cursor` to resume from a position. ## Return value -A `Promise` that resolves to an array of [`Team`](/Teams/#team) objects, or to a `{ items, cursor? }` page when a pagination option is given. With `stream: true` it returns an async iterator of pages instead. +A `Promise` that resolves to an array of `Team` objects, or to a `{ items, cursor? }` page when a pagination option is given. With `stream: true` it returns an async iterator of pages instead. + +#### `Team` + +| Field | Type | Description | +| -- | -- | -- | +| `uid` | `string` | The team's stable identifier — pass this, not the handle. | +| `name` | `string \| null` | Its display name. | +| `handle` | `string \| null` | Its short handle, unique while the team exists. | +| `isOwner` | `boolean` | Whether the caller is the owner account. | +| `createdAt` | `string` | When it was created. | + +## Errors + +A rejection carries an `Error` with a stable `code`: + +| Code | Meaning | +| -- | -- | +| `invalid_request` | Refused before reaching the server — a blank `uid`, or an `offset` on a keyset list. | +| `unauthorized` | Not signed in. | +| `account_is_not_verified` | The caller's email has not been confirmed. | +| `not_found` | Teams are turned off on this deployment. | +| `too_many_requests` | The rate limit was exceeded. See [Rate Limits & Quotas](/rate-limits-and-quotas/). | ## Examples diff --git a/src/docs/src/Teams/listDirectory.md b/src/docs/src/Teams/listDirectory.md index 4193d482d..e72d0b610 100644 --- a/src/docs/src/Teams/listDirectory.md +++ b/src/docs/src/Teams/listDirectory.md @@ -30,17 +30,44 @@ The team's `uid`, from [`list()`](/Teams/list/). #### `options` (Object) (optional) -The standard list options — `limit`, `cursor`, `includeTotal` and `stream`. See -[Pagination](/Teams/#pagination) for what each form returns. `offset` is not -accepted. +The standard list options. All four are optional, and they decide the shape of what resolves: + +| Call | Resolves to | +| -- | -- | +| No options | The whole set as an array, fetched page by page under the hood | +| `{ limit }` | An array, capped at one page | +| `{ cursor }` or `{ includeTotal: true }` | One `{ items, cursor? }` page. `cursor` is absent on the last page | +| `{ stream: true }` | An async iterator of `{ items, cursor? }` pages | + +This route is keyset-paginated, so `offset` is not accepted — passing it throws `invalid_request`. Pass `cursor` to resume from a position. ## Return value A `Promise` that resolves to an array of -[`TeamDirectoryEntry`](/Teams/#teamdirectoryentry) objects, or to a +`TeamDirectoryEntry` objects, or to a `{ items, cursor? }` page when a pagination option is given. With `stream: true` it returns an async iterator of pages instead. +#### `TeamDirectoryEntry` + +| Field | Type | Description | +| -- | -- | -- | +| `username` | `string` | A colleague's Puter username. | +| `uuid` | `string` | Their stable account identifier. | + +## Errors + +A rejection carries an `Error` with a stable `code`: + +| Code | Meaning | +| -- | -- | +| `invalid_request` | Refused before reaching the server — a blank `uid`, or an `offset` on a keyset list. | +| `unauthorized` | Not signed in. | +| `account_is_not_verified` | The caller's email has not been confirmed. | +| `not_found` | Teams are turned off on this deployment. | +| `team_not_found` | No such team, the caller is not a member of it, or the owner has not opened the directory to apps. | +| `too_many_requests` | The rate limit was exceeded. See [Rate Limits & Quotas](/rate-limits-and-quotas/). | + ## Examples Suggest colleagues to share with diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 920242498..760d58229 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -485,10 +485,10 @@ const en = { blocked_senders: 'Blocked people', blocked_senders_summary: 'People who can’t share with you', blocked_senders_note: - 'Blocked people can’t share anything new with you, and anything they share through a team you’re in stays hidden from you. What they already shared directly stays until you remove it.', + 'Blocked people can’t share anything new with you, and you stop being notified about what they share. Files they share with a team you’re both on still reach you — that grant is the team’s. What they already shared directly stays until you remove it.', blocked_all: 'Don’t let anyone share with me', blocked_all_note: - 'Refuses every new share, whoever it’s from. What’s already shared with you stays, and shares made to a team you belong to still arrive — block the sender, or leave the team.', + 'Refuses every new share, whoever it’s from. What’s already shared with you stays, and shares made to a team you belong to still arrive — leaving the team is what ends those.', blocked_all_on: 'New shares are now refused from everyone', blocked_all_off: 'You’re accepting shares again', blocked_add: 'Block someone',