mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix channel visibility controls (#940)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -37,7 +37,7 @@ const overrides = new Map([
|
||||
["src-tauri/src/managed_agents/persona_card.rs", 1050],
|
||||
["src-tauri/src/huddle/tts.rs", 1364],
|
||||
["src/shared/api/tauri.ts", 1196],
|
||||
["src-tauri/src/nostr_convert.rs", 1116],
|
||||
["src-tauri/src/nostr_convert.rs", 1126],
|
||||
["src/shared/api/relayClientSession.ts", 1022],
|
||||
["src-tauri/src/migration.rs", 1295],
|
||||
["src-tauri/src/managed_agents/teams.rs", 1020],
|
||||
|
||||
@@ -79,11 +79,10 @@ pub fn channel_info_from_event(
|
||||
"stream".to_string()
|
||||
}
|
||||
});
|
||||
// Prefer explicit ["public"] tag; fall back to NIP-29's absence-of-"private"
|
||||
// convention for relays that don't yet emit the explicit tag.
|
||||
let visibility = if has_tag(event, "public") {
|
||||
let visibility_tag = first_tag_value(event, "visibility");
|
||||
let visibility = if has_tag(event, "public") || visibility_tag == Some("open") {
|
||||
"open".to_string()
|
||||
} else if has_tag(event, "private") {
|
||||
} else if has_tag(event, "private") || visibility_tag == Some("private") {
|
||||
"private".to_string()
|
||||
} else {
|
||||
"open".to_string()
|
||||
@@ -161,10 +160,10 @@ pub fn channel_detail_from_event(event: &Event) -> Result<ChannelDetailInfo, Str
|
||||
"stream".to_string()
|
||||
}
|
||||
});
|
||||
// Prefer explicit ["public"]; fall back to NIP-29 absence-of-"private".
|
||||
let visibility = if has_tag(event, "public") {
|
||||
let visibility_tag = first_tag_value(event, "visibility");
|
||||
let visibility = if has_tag(event, "public") || visibility_tag == Some("open") {
|
||||
"open".to_string()
|
||||
} else if has_tag(event, "private") {
|
||||
} else if has_tag(event, "private") || visibility_tag == Some("private") {
|
||||
"private".to_string()
|
||||
} else {
|
||||
"open".to_string()
|
||||
@@ -678,8 +677,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_private_when_private_tag_present() {
|
||||
// Explicit ["private"] tag → private (NIP-29 convention).
|
||||
fn channel_info_private_when_visibility_tag_present() {
|
||||
let e = ev(
|
||||
39000,
|
||||
"",
|
||||
@@ -687,12 +685,14 @@ mod tests {
|
||||
vec!["d", "u"],
|
||||
vec!["name", "n"],
|
||||
vec!["t", "forum"],
|
||||
vec!["private"],
|
||||
vec!["visibility", "private"],
|
||||
vec!["ttl", "86400"],
|
||||
],
|
||||
);
|
||||
let info = channel_info_from_event(&e, None, None).unwrap();
|
||||
assert_eq!(info.visibility, "private");
|
||||
assert_eq!(info.channel_type, "forum");
|
||||
assert_eq!(info.ttl_seconds, Some(86400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -753,6 +753,9 @@ mod tests {
|
||||
vec!["topic", "tt"],
|
||||
vec!["purpose", "pp"],
|
||||
vec!["t", "dm"],
|
||||
vec!["visibility", "private"],
|
||||
vec!["ttl", "86400"],
|
||||
vec!["ttl_deadline", "2026-06-11T00:00:00Z"],
|
||||
],
|
||||
);
|
||||
let d = channel_detail_from_event(&e).unwrap();
|
||||
@@ -760,6 +763,9 @@ mod tests {
|
||||
assert_eq!(d.topic.as_deref(), Some("tt"));
|
||||
assert_eq!(d.purpose.as_deref(), Some("pp"));
|
||||
assert_eq!(d.channel_type, "dm");
|
||||
assert_eq!(d.visibility, "private");
|
||||
assert_eq!(d.ttl_seconds, Some(86400));
|
||||
assert_eq!(d.ttl_deadline.as_deref(), Some("2026-06-11T00:00:00Z"));
|
||||
assert!(d.created_at.ends_with("Z"));
|
||||
assert_eq!(d.created_by, e.pubkey.to_hex());
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ type ChannelManagementSheetProps = {
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_EPHEMERAL_TTL_SECONDS = 24 * 60 * 60;
|
||||
|
||||
function MetadataPill({
|
||||
icon: Icon,
|
||||
label,
|
||||
@@ -123,7 +125,8 @@ export function ChannelManagementSheet({
|
||||
const channelId = channel?.id ?? null;
|
||||
const detailsQuery = useChannelDetailsQuery(channelId, open);
|
||||
const membersQuery = useChannelMembersQuery(channelId, open);
|
||||
const updateChannelMutation = useUpdateChannelMutation(channelId);
|
||||
const updateChannelDetailsMutation = useUpdateChannelMutation(channelId);
|
||||
const updateChannelLifecycleMutation = useUpdateChannelMutation(channelId);
|
||||
const setTopicMutation = useSetChannelTopicMutation(channelId);
|
||||
const setPurposeMutation = useSetChannelPurposeMutation(channelId);
|
||||
const archiveChannelMutation = useArchiveChannelMutation(channelId);
|
||||
@@ -243,24 +246,19 @@ export function ChannelManagementSheet({
|
||||
const nextVisibility: "open" | "private" = isPrivateDraft
|
||||
? "private"
|
||||
: "open";
|
||||
// Ephemeral on with a parsed duration → set those seconds; ephemeral off →
|
||||
// clear (null). An ephemeral toggle with an empty/invalid field leaves the
|
||||
// existing TTL untouched (undefined) so an accidental clear can't happen.
|
||||
const nextTtlSeconds: number | null | undefined = isEphemeralDraft
|
||||
? (parsedTtlSeconds ?? undefined)
|
||||
const nextTtlSeconds: number | null = isEphemeralDraft
|
||||
? (parsedTtlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS)
|
||||
: null;
|
||||
const lifecycleDirty =
|
||||
nextVisibility !== currentVisibility ||
|
||||
(nextTtlSeconds !== undefined && nextTtlSeconds !== currentTtlSeconds);
|
||||
nextTtlSeconds !== currentTtlSeconds;
|
||||
|
||||
function handleSaveLifecycle() {
|
||||
void updateChannelMutation.mutateAsync({
|
||||
void updateChannelLifecycleMutation.mutateAsync({
|
||||
visibility:
|
||||
nextVisibility !== currentVisibility ? nextVisibility : undefined,
|
||||
ttlSeconds:
|
||||
nextTtlSeconds !== undefined && nextTtlSeconds !== currentTtlSeconds
|
||||
? nextTtlSeconds
|
||||
: undefined,
|
||||
nextTtlSeconds !== currentTtlSeconds ? nextTtlSeconds : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -360,7 +358,7 @@ export function ChannelManagementSheet({
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void updateChannelMutation.mutateAsync({
|
||||
void updateChannelDetailsMutation.mutateAsync({
|
||||
description: descriptionDraft.trim() || undefined,
|
||||
name: nameDraft.trim() || undefined,
|
||||
});
|
||||
@@ -372,7 +370,9 @@ export function ChannelManagementSheet({
|
||||
</label>
|
||||
<Input
|
||||
data-testid="channel-management-name"
|
||||
disabled={!canManageChannel || updateChannelMutation.isPending}
|
||||
disabled={
|
||||
!canManageChannel || updateChannelDetailsMutation.isPending
|
||||
}
|
||||
id="channel-name"
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
value={nameDraft}
|
||||
@@ -388,7 +388,9 @@ export function ChannelManagementSheet({
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
data-testid="channel-management-description"
|
||||
disabled={!canManageChannel || updateChannelMutation.isPending}
|
||||
disabled={
|
||||
!canManageChannel || updateChannelDetailsMutation.isPending
|
||||
}
|
||||
id="channel-description"
|
||||
onChange={(event) => setDescriptionDraft(event.target.value)}
|
||||
value={descriptionDraft}
|
||||
@@ -396,15 +398,19 @@ export function ChannelManagementSheet({
|
||||
</div>
|
||||
<Button
|
||||
data-testid="channel-management-save-details"
|
||||
disabled={!canManageChannel || updateChannelMutation.isPending}
|
||||
disabled={
|
||||
!canManageChannel || updateChannelDetailsMutation.isPending
|
||||
}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{updateChannelMutation.isPending ? "Saving..." : "Save details"}
|
||||
{updateChannelDetailsMutation.isPending
|
||||
? "Saving..."
|
||||
: "Save details"}
|
||||
</Button>
|
||||
{updateChannelMutation.error instanceof Error ? (
|
||||
{updateChannelDetailsMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{updateChannelMutation.error.message}
|
||||
{updateChannelDetailsMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
@@ -425,7 +431,8 @@ export function ChannelManagementSheet({
|
||||
checked={isPrivateDraft}
|
||||
data-testid="channel-management-private-toggle"
|
||||
disabled={
|
||||
!canManageChannel || updateChannelMutation.isPending
|
||||
!canManageChannel ||
|
||||
updateChannelLifecycleMutation.isPending
|
||||
}
|
||||
onCheckedChange={setIsPrivateDraft}
|
||||
/>
|
||||
@@ -442,7 +449,8 @@ export function ChannelManagementSheet({
|
||||
checked={isEphemeralDraft}
|
||||
data-testid="channel-management-ephemeral-toggle"
|
||||
disabled={
|
||||
!canManageChannel || updateChannelMutation.isPending
|
||||
!canManageChannel ||
|
||||
updateChannelLifecycleMutation.isPending
|
||||
}
|
||||
onCheckedChange={setIsEphemeralDraft}
|
||||
/>
|
||||
@@ -457,7 +465,8 @@ export function ChannelManagementSheet({
|
||||
aria-invalid={ttlInvalid}
|
||||
data-testid="channel-management-ttl"
|
||||
disabled={
|
||||
!canManageChannel || updateChannelMutation.isPending
|
||||
!canManageChannel ||
|
||||
updateChannelLifecycleMutation.isPending
|
||||
}
|
||||
id="channel-ttl"
|
||||
onChange={(event) => setTtlDraft(event.target.value)}
|
||||
@@ -472,7 +481,7 @@ export function ChannelManagementSheet({
|
||||
>
|
||||
{ttlInvalid
|
||||
? "Enter a duration like 1d, 12h, or 30m."
|
||||
: "Resets the deletion countdown from now whenever changed."}
|
||||
: "Defaults to 1d when left empty. Resets the deletion countdown from now whenever changed."}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -481,7 +490,7 @@ export function ChannelManagementSheet({
|
||||
data-testid="channel-management-save-lifecycle"
|
||||
disabled={
|
||||
!canManageChannel ||
|
||||
updateChannelMutation.isPending ||
|
||||
updateChannelLifecycleMutation.isPending ||
|
||||
ttlInvalid ||
|
||||
!lifecycleDirty
|
||||
}
|
||||
@@ -489,7 +498,7 @@ export function ChannelManagementSheet({
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{updateChannelMutation.isPending
|
||||
{updateChannelLifecycleMutation.isPending
|
||||
? "Saving..."
|
||||
: "Save visibility"}
|
||||
</Button>
|
||||
|
||||
@@ -44,6 +44,7 @@ type E2eConfig = {
|
||||
profileReadDelayMs?: number;
|
||||
profileReadError?: string;
|
||||
profileUpdateError?: string;
|
||||
updateChannelDelayMs?: number;
|
||||
stallWebsocketSends?: boolean;
|
||||
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
|
||||
// semantics. These three drive the archive-button gate matrix in
|
||||
@@ -501,6 +502,10 @@ declare global {
|
||||
interface Window {
|
||||
__SPROUT_E2E__?: E2eConfig;
|
||||
__SPROUT_E2E_COMMANDS__?: string[];
|
||||
__SPROUT_E2E_COMMAND_LOG__?: Array<{
|
||||
command: string;
|
||||
payload: unknown;
|
||||
}>;
|
||||
__SPROUT_E2E_WEBVIEW_ZOOM__?: number;
|
||||
__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
||||
channelName: string;
|
||||
@@ -3243,6 +3248,11 @@ async function handleUpdateChannel(
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const delayMs = config?.mock?.updateChannelDelayMs ?? 0;
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel = getMockChannel(args.channelId);
|
||||
@@ -5386,6 +5396,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
mockWebsocketSendMutexWedged = false;
|
||||
mockWindows("main");
|
||||
window.__SPROUT_E2E_COMMANDS__ = [];
|
||||
window.__SPROUT_E2E_COMMAND_LOG__ = [];
|
||||
window.__SPROUT_E2E_SIGNED_EVENTS__ = [];
|
||||
window.__SPROUT_E2E_WEBVIEW_ZOOM__ = 1;
|
||||
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({
|
||||
@@ -5502,6 +5513,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
const activeConfig = getConfig();
|
||||
const identity = getActiveIdentity(activeConfig);
|
||||
window.__SPROUT_E2E_COMMANDS__?.push(command);
|
||||
window.__SPROUT_E2E_COMMAND_LOG__?.push({ command, payload });
|
||||
|
||||
switch (command) {
|
||||
case "mesh_availability":
|
||||
|
||||
@@ -121,4 +121,65 @@ test.describe("channel controls screenshots", () => {
|
||||
|
||||
await sheet.screenshot({ path: `${SHOTS}/06-management-sheet.png` });
|
||||
});
|
||||
|
||||
test("07 — saving lifecycle leaves details save idle", async ({ page }) => {
|
||||
await installMockBridge(page, { updateChannelDelayMs: 500 });
|
||||
await openManagementSheet(page);
|
||||
|
||||
const sheet = page.getByTestId("channel-management-sheet");
|
||||
await page.getByTestId("channel-management-ephemeral-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("channel-management-save-lifecycle"),
|
||||
).toBeEnabled();
|
||||
|
||||
await page.getByTestId("channel-management-save-lifecycle").click();
|
||||
await expect(
|
||||
page.getByTestId("channel-management-save-lifecycle"),
|
||||
).toHaveText("Saving...");
|
||||
await expect(
|
||||
page.getByTestId("channel-management-save-details"),
|
||||
).toHaveText("Save details");
|
||||
await sheet.screenshot({
|
||||
path: `${SHOTS}/07-lifecycle-saving-details-idle.png`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByTestId("channel-management-save-lifecycle"),
|
||||
).toHaveText("Save visibility");
|
||||
});
|
||||
|
||||
test("08 — saved ephemeral lifecycle is reflected after reopen", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await openManagementSheet(page);
|
||||
|
||||
await page.getByTestId("channel-management-private-toggle").click();
|
||||
await page.getByTestId("channel-management-ephemeral-toggle").click();
|
||||
await page.getByTestId("channel-management-save-lifecycle").click();
|
||||
await expect(
|
||||
page.getByTestId("channel-management-save-lifecycle"),
|
||||
).toHaveText("Save visibility");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(
|
||||
page.getByTestId("channel-management-sheet"),
|
||||
).not.toBeVisible();
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
|
||||
const lifecycle = page.getByTestId("channel-management-lifecycle");
|
||||
await lifecycle.scrollIntoViewIfNeeded();
|
||||
await expect(
|
||||
page.getByTestId("channel-management-private-toggle"),
|
||||
).toHaveAttribute("data-state", "checked");
|
||||
await expect(
|
||||
page.getByTestId("channel-management-ephemeral-toggle"),
|
||||
).toHaveAttribute("data-state", "checked");
|
||||
await expect(page.getByTestId("channel-management-ttl")).toHaveValue("1d");
|
||||
await settle(page);
|
||||
|
||||
await lifecycle.screenshot({
|
||||
path: `${SHOTS}/08-ephemeral-persisted-after-reopen.png`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,13 +115,25 @@ async function getManagedAgentPubkey(
|
||||
}
|
||||
|
||||
async function readCommandLog(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(() => {
|
||||
return (
|
||||
(window as Window & { __SPROUT_E2E_COMMANDS__?: string[] })
|
||||
.__SPROUT_E2E_COMMANDS__ ?? []
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function readCommandPayloadLog(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(() => {
|
||||
return (
|
||||
(
|
||||
window as Window & {
|
||||
__SPROUT_E2E_COMMANDS__?: string[];
|
||||
__SPROUT_E2E_COMMAND_LOG__?: Array<{
|
||||
command: string;
|
||||
payload: unknown;
|
||||
}>;
|
||||
}
|
||||
).__SPROUT_E2E_COMMANDS__ ?? []
|
||||
).__SPROUT_E2E_COMMAND_LOG__ ?? []
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -748,6 +760,108 @@ test("manage channel updates details and context", async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test("manage channel updates visibility and ephemeral lifecycle independently", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openChannelManagement(page, "general");
|
||||
|
||||
const saveDetailsButton = page.getByTestId("channel-management-save-details");
|
||||
const saveLifecycleButton = page.getByTestId(
|
||||
"channel-management-save-lifecycle",
|
||||
);
|
||||
|
||||
await expect(saveLifecycleButton).toBeDisabled();
|
||||
|
||||
await page.getByTestId("channel-management-private-toggle").click();
|
||||
await page.getByTestId("channel-management-ephemeral-toggle").click();
|
||||
await expect(page.getByTestId("channel-management-ttl")).toBeVisible();
|
||||
await expect(saveLifecycleButton).toBeEnabled();
|
||||
|
||||
const commandCountBeforeEnable = (await readCommandPayloadLog(page)).length;
|
||||
await saveLifecycleButton.click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await readCommandPayloadLog(page)).slice(commandCountBeforeEnable),
|
||||
)
|
||||
.toContainEqual(
|
||||
expect.objectContaining({
|
||||
command: "update_channel",
|
||||
payload: expect.objectContaining({
|
||||
input: expect.objectContaining({ ttlSeconds: 86400 }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(saveLifecycleButton).toHaveText("Save visibility");
|
||||
await expect(saveDetailsButton).toHaveText("Save details");
|
||||
|
||||
const channelAfterEnable = await invokeMockCommand<{
|
||||
ttl_seconds: number | null;
|
||||
visibility: string;
|
||||
}>(page, "get_channel_details", {
|
||||
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
});
|
||||
expect(channelAfterEnable).toMatchObject({
|
||||
ttl_seconds: 86400,
|
||||
visibility: "private",
|
||||
});
|
||||
|
||||
await closeChannelManagement(page);
|
||||
await openChannelManagement(page, "general");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("channel-management-private-toggle"),
|
||||
).toHaveAttribute("data-state", "checked");
|
||||
await expect(
|
||||
page.getByTestId("channel-management-ephemeral-toggle"),
|
||||
).toHaveAttribute("data-state", "checked");
|
||||
await expect(page.getByTestId("channel-management-ttl")).toHaveValue("1d");
|
||||
|
||||
await page.getByTestId("channel-management-private-toggle").click();
|
||||
await page.getByTestId("channel-management-ephemeral-toggle").click();
|
||||
await expect(saveLifecycleButton).toBeEnabled();
|
||||
|
||||
const commandCountBeforeDisable = (await readCommandPayloadLog(page)).length;
|
||||
await saveLifecycleButton.click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await readCommandPayloadLog(page)).slice(commandCountBeforeDisable),
|
||||
)
|
||||
.toContainEqual(
|
||||
expect.objectContaining({
|
||||
command: "update_channel",
|
||||
payload: expect.objectContaining({
|
||||
input: expect.objectContaining({ ttlSeconds: null }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(saveLifecycleButton).toHaveText("Save visibility");
|
||||
await expect(saveDetailsButton).toHaveText("Save details");
|
||||
await expect(page.getByTestId("channel-management-ttl")).toHaveCount(0);
|
||||
|
||||
const channelAfterDisable = await invokeMockCommand<{
|
||||
ttl_seconds: number | null;
|
||||
visibility: string;
|
||||
}>(page, "get_channel_details", {
|
||||
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
});
|
||||
expect(channelAfterDisable).toMatchObject({
|
||||
ttl_seconds: null,
|
||||
visibility: "open",
|
||||
});
|
||||
|
||||
await closeChannelManagement(page);
|
||||
await openChannelManagement(page, "general");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("channel-management-private-toggle"),
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(
|
||||
page.getByTestId("channel-management-ephemeral-toggle"),
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(page.getByTestId("channel-management-ttl")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("manage channel keeps canvas near the top of the sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -51,6 +51,7 @@ type MockBridgeOptions = {
|
||||
profileReadDelayMs?: number;
|
||||
profileReadError?: string;
|
||||
profileUpdateError?: string;
|
||||
updateChannelDelayMs?: number;
|
||||
stallWebsocketSends?: boolean;
|
||||
// NIP-IA gate inputs — drive the archive-button gate matrix in
|
||||
// tests/e2e/identity-archive.spec.ts.
|
||||
|
||||
Reference in New Issue
Block a user