fix: team shares are not subject to a recipient's per-sender block

Review call from the ticket's author: a team share is the team's, not
one colleague's to withhold from another, so a personal block should not
hide it. This drops the enforcement added earlier on the branch — the
listing, its total and the fs-event fan-out no longer filter team rows
by the recipient's block list, and the SQL fragment that did it goes
with them.

What a block still does for a team share is suppress the notification,
which was already true before this branch: it stops the interruption
without pretending to stop the access. Leaving the team is what ends
that. Said so in the block API docs, the settings copy and the code,
since the mismatch between the two was the original complaint.

The unshare sweep is untouched — that half of the ticket stands.
This commit is contained in:
Juan Castro
2026-09-21 10:28:50 -04:00
parent 4e821c128c
commit 3f335939b3
9 changed files with 197 additions and 232 deletions
+10 -6
View File
@@ -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,
+27 -85
View File
@@ -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 () => {
+11 -21
View File
@@ -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(
@@ -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;
+2 -1
View File
@@ -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)
+81 -98
View File
@@ -6,115 +6,98 @@ platforms: [websites, apps]
<div class="info">The Teams API is in beta. Method shapes, limits, and behavior may change between releases.</div>
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
<strong class="example-title">Detect whether the user is on a team</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
let teams = [];
try {
teams = await puter.teams.list();
} catch (e) {
// Teams are unavailable on this deployment.
}
puter.print(teams.length
? `On ${teams.length} team(s): ${teams.map(t => t.name).join(', ')}`
: 'Not on a team');
})();
</script>
</body>
</html>
```
## Availability
<strong class="example-title">Suggest a colleague to share with</strong>
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
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const [team] = await puter.teams.list();
if ( ! team ) return puter.print('Not on a team');
`puter.teams.list()` is how an app tells the two apart: it rejects when the
feature is off, and resolves to an empty array when it is on and the caller has
no team.
```js
let teams = [];
try {
teams = await puter.teams.list();
} catch (e) {
// Teams are unavailable here; show nothing.
}
try {
const colleagues = await puter.teams.listDirectory(team.uid);
colleagues.forEach(c => puter.print(`${c.username}<br>`));
} catch (e) {
// The owner has not opened the directory to apps.
puter.print('No directory for this team');
}
})();
</script>
</body>
</html>
```
## `uid`, not `handle`
<strong class="example-title">Share a file with the whole team</strong>
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
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const [team] = await puter.teams.list();
await puter.fs.write('report.txt', 'Quarterly numbers');
await puter.fs.share({
path: 'report.txt',
recipient: { team: team.uid },
mode: 'read',
});
puter.print(`Shared with everyone on ${team.name}`);
})();
</script>
</body>
</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
+33 -2
View File
@@ -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
+31 -4
View File
@@ -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
<strong class="example-title">Suggest colleagues to share with</strong>
+2 -2
View File
@@ -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',