Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/components/OrgForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const propTypes = {
longitude: PropTypes.number.isRequired,
}),
site: PropTypes.string,
bsky_handle: PropTypes.string,
about: PropTypes.string,
}),
};
Expand All @@ -77,7 +78,7 @@ const OrgForm = ({ defaultData = {}, loading = false, ...rest }) => {
const logoRef = useRef();
const [logo, setLogo] = useState(defaultData.logo);

const { id, type = 'studio', name, location: l, site, about } = defaultData;
const { id, type = 'studio', name, location: l, site, bsky_handle, about } = defaultData;
const {
handleSubmit,
register,
Expand All @@ -101,6 +102,7 @@ const OrgForm = ({ defaultData = {}, loading = false, ...rest }) => {
value: l || null,
},
site,
bsky_handle: bsky_handle ?? '',
about,
},
});
Expand Down Expand Up @@ -263,6 +265,11 @@ const OrgForm = ({ defaultData = {}, loading = false, ...rest }) => {
{errors.site && errors.site.message}
</Field.ErrorText>
</Field.Root>
<Field.Root gridColumn="1 / 3">
<Field.Label>Bluesky handle</Field.Label>
<Input {...register('bsky_handle')} placeholder="studio.bsky.social" />
<Field.HelperText>Checked with Bluesky when you save.</Field.HelperText>
</Field.Root>
<Field.Root gridColumn="1 / 3" invalid={errors.about}>
<Field.Label>About</Field.Label>
<Textarea
Expand Down
10 changes: 10 additions & 0 deletions app/routes/org.$id.edit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { authorizer, canWrite } from '../utils/auth.server';
import { parseFormWithUploads } from '../utils/createUploadHandler.server';
import { toaster } from '../components/ui/toaster';
import OrgForm from '../components/OrgForm';
import { checkBlueskyHandle } from '../utils/bluesky.server';

const uuidRegex =
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;
Expand Down Expand Up @@ -70,6 +71,14 @@ export async function action(args) {
longitude: parseFloat(data.get('longitude')) || null,
};

// Checked before the update, so the form shows why it failed (#161).
let bsky_handle;
try {
bsky_handle = await checkBlueskyHandle(data.get('bsky_handle'));
} catch (err) {
return { error: err.message, values: Object.fromEntries(data) };
}

try {
const [, igdb_slug] =
(data.get('igdb_url') || '').match(/companies\/(.+)/) || [];
Expand All @@ -81,6 +90,7 @@ export async function action(args) {
lastModifiedById: currentUser.id,
type: data.get('type').toLowerCase(),
site: data.get('site'),
bsky_handle,
about: data.get('about'),
// igdb_slug,
location: Object.values(location).some((l) => l !== null)
Expand Down
13 changes: 13 additions & 0 deletions app/routes/org.$id.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import usePlaceholder from '../hooks/usePlaceholder';
import Markdown from '../components/Markdown';
import ActionMenu from '../components/ActionMenu';
import { ogUrl } from '../utils/meta';
import { FaBluesky } from 'react-icons/fa6';

const TYPES_COLORS = {
studio: 'yellow',
Expand Down Expand Up @@ -165,6 +166,7 @@ const Org = () => {
name,
type,
site,
bsky_handle,
about,
location,
logo,
Expand Down Expand Up @@ -201,6 +203,17 @@ const Org = () => {
{type}
</Badge>
</DarkMode>
{bsky_handle && (
<ChakraLink
href={`https://bsky.app/profile/${bsky_handle}`}
ml={2}
target="_blank"
rel="noopener noreferrer"
aria-label={`Bluesky: @${bsky_handle}`}
>
<FaBluesky />@{bsky_handle}
</ChakraLink>
)}
{site && (
<ChakraLink
href={site}
Expand Down
3 changes: 3 additions & 0 deletions app/routes/orgs.create.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { notifyDiscord } from '../utils/discordNotification.server';
import { parseFormWithUploads } from '../utils/createUploadHandler.server';
import { toaster } from '../components/ui/toaster';
import OrgForm from '../components/OrgForm';
import { checkBlueskyHandle } from '../utils/bluesky.server';

export async function action(args) {
const { request } = args;
Expand All @@ -34,6 +35,8 @@ export async function action(args) {
name: data.get('name'),
type: data.get('type').toLowerCase(),
site: data.get('site'),
// Throws a message for the form if it doesn't resolve (#161).
bsky_handle: await checkBlueskyHandle(data.get('bsky_handle')),
about: data.get('about'),
// igdb_slug,
location: Object.values(location).some((l) => l !== null)
Expand Down
33 changes: 33 additions & 0 deletions app/utils/bluesky.server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Bluesky handles on studios and associations (#161).
*
* The public API is configurable (BLUESKY_API) so tests can stand in for it.
*/
const api = () => process.env.BLUESKY_API || 'https://public.api.bsky.app';

// A domain name: labels of letters, digits and hyphens, at least two.
const HANDLE = /^(?=.{3,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9]$/;

/** "@Studio.bsky.social" or a bsky.app profile URL → "studio.bsky.social". */
export function normalizeBlueskyHandle(value) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/^(https?:\/\/)?(www\.)?bsky\.app\/profile\//, '')
.replace(/^@/, '')
.replace(/\/+$/, '');
}

/**
* The normalised handle, or null for an empty field. Throws an Error with a
* message for the form when it isn't a handle or doesn't resolve.
*/
export async function checkBlueskyHandle(value) {
const handle = normalizeBlueskyHandle(value);
if (!handle) return null;
if (!HANDLE.test(handle)) throw new Error(`"${String(value).trim()}" isn't a Bluesky handle (like studio.bsky.social).`);

const response = await fetch(`${api()}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`).catch(() => null);
if (!response?.ok) throw new Error(`No Bluesky account is called "${handle}".`);
return handle;
}
37 changes: 37 additions & 0 deletions app/utils/bluesky.server.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { checkBlueskyHandle, normalizeBlueskyHandle } from './bluesky.server';

describe('normalizeBlueskyHandle', () => {
it.each([
['@studio.bsky.social', 'studio.bsky.social'],
[' Studio.BSKY.social ', 'studio.bsky.social'],
['https://bsky.app/profile/studio.example.com', 'studio.example.com'],
['bsky.app/profile/@studio.bsky.social/', 'studio.bsky.social'],
['', ''],
[null, ''],
])('%j → %j', (input, expected) => {
expect(normalizeBlueskyHandle(input)).toBe(expected);
});
});

describe('checkBlueskyHandle', () => {
afterEach(() => vi.unstubAllGlobals());

it('returns null for an empty field', async () => {
expect(await checkBlueskyHandle(' ')).toBeNull();
});

it('accepts a handle that resolves', async () => {
const fetchMock = vi.fn(async () => Response.json({ did: 'did:plc:abc' }));
vi.stubGlobal('fetch', fetchMock);
expect(await checkBlueskyHandle('@Studio.bsky.social')).toBe('studio.bsky.social');
expect(String(fetchMock.mock.calls[0][0])).toContain('com.atproto.identity.resolveHandle?handle=studio.bsky.social');
});

it('rejects a malformed or unknown handle with a readable message', async () => {
await expect(checkBlueskyHandle('not a handle')).rejects.toThrow('"not a handle" isn\'t a Bluesky handle');
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ error: 'InvalidRequest' }, { status: 400 })));
await expect(checkBlueskyHandle('nobody.bsky.social')).rejects.toThrow('No Bluesky account is called "nobody.bsky.social"');
});
});
36 changes: 35 additions & 1 deletion e2e/orgs.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from './helpers';
import { expect, test, MEMBER, signIn } from './helpers';

test('filters narrow the studios list', async ({ page }) => {
await page.goto('/studios');
Expand All @@ -7,3 +7,37 @@ test('filters narrow the studios list', async ({ page }) => {
await expect(page).toHaveURL(/has_games=on/);
expect(await page.locator('a[href^="/org/"]').count()).toBeLessThanOrEqual(before);
});

// #161: studios and associations can show their Bluesky account.
test.describe('Bluesky handles on organizations', () => {
let server;
test.beforeAll(async () => {
const { createServer } = await import('node:http');
// Knows handles ending in .bsky.social; any other is "Unable to resolve".
server = createServer((req, res) => {
const handle = new URL(req.url, 'http://x').searchParams.get('handle') ?? '';
const known = handle.endsWith('.bsky.social');
res.writeHead(known ? 200 : 400, { 'content-type': 'application/json' });
res.end(JSON.stringify(known ? { did: 'did:plc:test' } : { error: 'InvalidRequest' }));
});
await new Promise((resolve) => server.listen(3198, '127.0.0.1', resolve));
});
test.afterAll(() => new Promise((resolve) => server.close(resolve)));

test('a handle is normalised, checked and linked; an unknown one is refused', async ({ page }) => {
await signIn(page, MEMBER);
await page.goto('/orgs/create');
const name = `Sky Studio ${Date.now() % 100000}`;
await page.getByText('Association', { exact: true }).click();
await page.getByLabel(/^name/i).fill(name);
await page.getByLabel('Bluesky handle').fill('nobody.example.com');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByText('No Bluesky account is called "nobody.example.com".')).toBeVisible();

await page.getByLabel('Bluesky handle').fill('@Sky-Studio.bsky.social');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page).toHaveURL(/\/org\/[0-9a-f-]{36}$/);
const link = page.getByRole('link', { name: 'Bluesky: @sky-studio.bsky.social' });
await expect(link).toHaveAttribute('href', 'https://bsky.app/profile/sky-studio.bsky.social');
});
});
7 changes: 6 additions & 1 deletion playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ export default defineConfig({
command:
'npx react-router build && node node_modules/@react-router/serve/bin.js build/server/index.js',
url: `http://localhost:${PORT}/about`,
env: { PORT: String(PORT), NODE_ENV: 'development' },
env: {
PORT: String(PORT),
NODE_ENV: 'development',
// A local stand-in for Bluesky's public API (see orgs.spec.js).
BLUESKY_API: 'http://127.0.0.1:3198',
},
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- #161: an organisation's Bluesky handle (e.g. studio.bsky.social).
ALTER TABLE "entity" ADD COLUMN "bsky_handle" VARCHAR(253);
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ model entity {
updated_at DateTime? @updatedAt
logo_id String? @db.Uuid
site String?
/// #161: the org's Bluesky handle, e.g. studio.bsky.social.
bsky_handle String? @db.VarChar(253)
lastModifiedById String? @db.Uuid
location location? @relation(fields: [location_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
logo image? @relation(fields: [logo_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
Expand Down
Loading