diff --git a/README.md b/README.md
index 54faa73..5c4ca06 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# atmo garden
-communities on bsky
+experiment for communities on bsky
## how it works
@@ -9,9 +9,4 @@ communities on bsky
- you can send a dm with a post to that profile and it gets posted to that profile
(if you add a text as a quote post, otherwise as a repost)
- follow the community profile to see the posts in your timeline (or go to cooking.atmo.garden)
-
-## todo
-
-- pinned post explaining communities
-- submit posts on site
-- limit title length?
\ No newline at end of file
+- community submissions can be gated by a list of allowed users or open to everyone
\ No newline at end of file
diff --git a/src/app.d.ts b/src/app.d.ts
index 9c8189c..e0e3dad 100644
--- a/src/app.d.ts
+++ b/src/app.d.ts
@@ -27,6 +27,13 @@ declare global {
ROOKERY_HOSTNAME: string;
ROOKERY_SIGNUP_SECRET: string;
CRON_SECRET?: string;
+ // Optional: credentials for adding newly-registered communities
+ // to the public atmo.garden discovery list. If any of these are
+ // missing, registration still succeeds but skips the list-add.
+ ATMO_GARDEN_PDS?: string;
+ ATMO_GARDEN_IDENTIFIER?: string;
+ ATMO_GARDEN_APP_PASSWORD?: string;
+ ATMO_GARDEN_LIST_RKEY?: string;
};
}
}
diff --git a/src/lib/reddit/SortTabs.svelte b/src/lib/reddit/SortTabs.svelte
new file mode 100644
index 0000000..45cb12f
--- /dev/null
+++ b/src/lib/reddit/SortTabs.svelte
@@ -0,0 +1,80 @@
+
+
+
+
+ setPrimaryTab('hot')} class={primaryBtnClass(primaryTab === 'hot')}>
+ Hot
+
+ setPrimaryTab('new')} class={primaryBtnClass(primaryTab === 'new')}>
+ New
+
+ setPrimaryTab('top')} class={primaryBtnClass(primaryTab === 'top')}>
+ Top
+
+
+ {#if primaryTab === 'top'}
+
+ {#each TOP_SORT_OPTIONS as option (option)}
+ setSort(option)} class={subBtnClass(sort === option)}>
+ {TOP_SORT_LABELS[option]}
+
+ {/each}
+
+ {/if}
+
diff --git a/src/lib/reddit/SubmitModal.svelte b/src/lib/reddit/SubmitModal.svelte
new file mode 100644
index 0000000..09a74c2
--- /dev/null
+++ b/src/lib/reddit/SubmitModal.svelte
@@ -0,0 +1,123 @@
+
+
+
+ Posting to c/{communityShort}
+
+
diff --git a/src/lib/reddit/bot.ts b/src/lib/reddit/bot.ts
index a47015d..1900a5c 100644
--- a/src/lib/reddit/bot.ts
+++ b/src/lib/reddit/bot.ts
@@ -56,12 +56,15 @@ export type CommunityConfig = {
accentColor: AccentColor;
whoCanSubmit: WhoCanSubmit;
listUri: string | null;
+ /** DID of the account that registered the community. Null for legacy rows. */
+ creator: string | null;
};
const DEFAULT_COMMUNITY_CONFIG: CommunityConfig = {
accentColor: DEFAULT_ACCENT_COLOR,
whoCanSubmit: 'everyone',
- listUri: null
+ listUri: null,
+ creator: null
};
/**
@@ -76,11 +79,16 @@ async function fetchCommunityConfig(pds: string, did: string): Promise {
+ const pds = env.ATMO_GARDEN_PDS;
+ const identifier = env.ATMO_GARDEN_IDENTIFIER;
+ const password = env.ATMO_GARDEN_APP_PASSWORD;
+ const listRkey = env.ATMO_GARDEN_LIST_RKEY;
+
+ if (!pds || !identifier || !password || !listRkey) {
+ console.log(
+ '[discovery-list] skipped — ATMO_GARDEN_* env vars not fully configured'
+ );
+ return;
+ }
+
+ try {
+ // 1. Log in.
+ const sessionRes = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ identifier, password })
+ });
+ if (!sessionRes.ok) {
+ throw new Error(
+ `createSession failed (${sessionRes.status}): ${await sessionRes.text()}`
+ );
+ }
+ const session = (await sessionRes.json()) as {
+ did: string;
+ accessJwt: string;
+ };
+
+ // 2. Build the list at-URI from the logged-in account's own DID +
+ // the configured rkey. This means the env var is just the short
+ // rkey — no handle resolution required.
+ const listUri = `at://${session.did}/app.bsky.graph.list/${listRkey}`;
+
+ // 3. Create the listitem record.
+ const createRes = await fetch(`${pds}/xrpc/com.atproto.repo.createRecord`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${session.accessJwt}`
+ },
+ body: JSON.stringify({
+ repo: session.did,
+ collection: 'app.bsky.graph.listitem',
+ record: {
+ $type: 'app.bsky.graph.listitem',
+ subject: communityDid,
+ list: listUri,
+ createdAt: new Date().toISOString()
+ }
+ })
+ });
+ if (!createRes.ok) {
+ throw new Error(
+ `createRecord listitem failed (${createRes.status}): ${await createRes.text()}`
+ );
+ }
+
+ console.log(`[discovery-list] added ${communityDid} to ${listUri}`);
+ } catch (e) {
+ console.error('[discovery-list] failed', e);
+ }
+}
+
// -------------------------------------------------------------------------
// Loading a client for a stored community
// -------------------------------------------------------------------------
@@ -406,6 +498,11 @@ export async function registerCommunity(
followers_count: followersCount
});
+ // Add the new community to the public atmo.garden discovery list so it
+ // shows up for anyone subscribed to the list on Bluesky. Non-fatal —
+ // the community is fully registered by this point regardless.
+ await addCommunityToDiscoveryList(env, account.did as Did);
+
return { did: account.did as Did, handle: account.handle };
}
@@ -612,6 +709,9 @@ export async function processCommunityDms(
function isAllowed(senderDid: string): boolean {
if (!gated) return true;
+ // Community creator bypasses the allowlist — they shouldn't be
+ // locked out of their own community by their own list config.
+ if (config.creator && config.creator === senderDid) return true;
return allowedMembers?.has(senderDid) ?? false;
}
@@ -777,6 +877,35 @@ async function createSubmissionPost(
// Web submissions via garden.atmo.submission records (jetstream-driven)
// -------------------------------------------------------------------------
+/**
+ * Is `viewerDid` allowed to submit to this community? Reads the community's
+ * `garden.atmo.community/self` record off the PDS to get the access policy;
+ * for list-gated communities also fetches the allowlist members.
+ *
+ * The community creator is always allowed, regardless of allowlist state —
+ * they registered the community and should never be locked out of their
+ * own space.
+ *
+ * Semantics for anonymous viewers (`viewerDid === null`):
+ * - non-gated community → true
+ * - list-gated → true (optimistic — user may still be prompted to log in
+ * before the submission actually goes through). This keeps the UI from
+ * hiding the button from signed-out visitors who would otherwise be
+ * allowed once they log in.
+ */
+export async function canUserSubmit(
+ pds: string,
+ communityDid: string,
+ viewerDid: string | null
+): Promise {
+ const config = await fetchCommunityConfig(pds, communityDid);
+ if (config.whoCanSubmit !== 'list' || !config.listUri) return true;
+ if (!viewerDid) return true;
+ if (config.creator && config.creator === viewerDid) return true;
+ const members = await fetchListMembers(config.listUri);
+ return members.has(viewerDid);
+}
+
/**
* Reason a web submission was dropped. Surfaced for logging; callers don't
* branch on these today.
@@ -806,12 +935,16 @@ export async function processWebSubmission(
// Access control: re-read the community config on every submission so
// allowlist flips take effect immediately. Cheap compared to the DM path
- // because we don't have a convo loop to amortize over.
+ // because we don't have a convo loop to amortize over. The creator is
+ // always allowed through, matching the UI-side check in `canUserSubmit`.
const config = await fetchCommunityConfig(row.pds, row.did);
if (config.whoCanSubmit === 'list' && config.listUri) {
- const members = await fetchListMembers(config.listUri);
- if (!members.has(input.submitterDid)) {
- return { ok: false, reason: 'not-allowed' };
+ const isCreator = config.creator && config.creator === input.submitterDid;
+ if (!isCreator) {
+ const members = await fetchListMembers(config.listUri);
+ if (!members.has(input.submitterDid)) {
+ return { ok: false, reason: 'not-allowed' };
+ }
}
}
diff --git a/src/lib/reddit/db.ts b/src/lib/reddit/db.ts
index 496d33a..9d6aae4 100644
--- a/src/lib/reddit/db.ts
+++ b/src/lib/reddit/db.ts
@@ -283,17 +283,26 @@ export async function getPostByUri(
export async function getCombinedFeed(
db: D1Database,
- limit = 50
+ limit = 50,
+ sort: PostSort = 'hot',
+ offset = 0
): Promise {
+ const { where, order } = sortClauses(sort);
+ // sortClauses returns a fragment starting with `AND` since it was
+ // written for the community-scoped query that always has a
+ // `WHERE p.community_did = ?` clause. For the home feed there's no
+ // base WHERE, so we rewrite the leading `AND` to `WHERE`.
+ const whereClause = where ? where.replace(/^\s*AND\s+/, 'WHERE ') : '';
const res = await db
.prepare(
`SELECT p.*, c.handle AS community_handle, c.display_name AS community_display_name, c.avatar AS community_avatar, c.accent_color AS community_accent_color
FROM posts p
JOIN communities c ON c.did = p.community_did
- ORDER BY p.indexed_at DESC
- LIMIT ?`
+ ${whereClause}
+ ORDER BY ${order}
+ LIMIT ? OFFSET ?`
)
- .bind(limit)
+ .bind(limit, offset)
.all();
return res.results ?? [];
}
diff --git a/src/lib/reddit/server/communities.remote.ts b/src/lib/reddit/server/communities.remote.ts
index 14b2cf3..f3d3157 100644
--- a/src/lib/reddit/server/communities.remote.ts
+++ b/src/lib/reddit/server/communities.remote.ts
@@ -11,7 +11,7 @@ import {
type PostWithCommunity,
type PostSort
} from '../db';
-import { registerCommunity } from '../bot';
+import { canUserSubmit, registerCommunity } from '../bot';
import { parseListUri } from '../list-uri';
import {
ACCENT_COLORS,
@@ -41,6 +41,11 @@ type PublicCommunity = Omit<
followersCount: number;
};
+/** PublicCommunity + viewer-specific access state. */
+type CommunityWithAccess = PublicCommunity & {
+ canSubmit: boolean;
+};
+
function sanitize(row: CommunityRow): PublicCommunity {
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
@@ -162,13 +167,23 @@ export const getCommunities = command(
export const getCommunity = command(
v.object({ handle: v.string() }),
- async (input): Promise => {
- const { platform } = getRequestEvent();
+ async (input): Promise => {
+ const { platform, locals } = getRequestEvent();
const env = platform?.env;
if (!env || !env.DB) return null;
const row = await getCommunityByHandle(env.DB, fullHandle(input.handle));
- return row ? sanitize(row) : null;
+ if (!row) return null;
+
+ // Viewer-specific: can this viewer submit to the community? Reads the
+ // allowlist off the community's PDS and (if gated) checks membership.
+ // Fails open — if the config fetch blows up, we don't want to hide the
+ // submit button from everyone.
+ const canSubmit = await canUserSubmit(row.pds, row.did, locals.did ?? null).catch(
+ () => true
+ );
+
+ return { ...sanitize(row), canSubmit };
}
);
@@ -209,12 +224,21 @@ export const getCommunityPost = command(
);
export const getHomeFeed = command(
- v.object({ limit: v.optional(v.number()) }),
+ v.object({
+ limit: v.optional(v.number()),
+ offset: v.optional(v.number()),
+ sort: v.optional(v.picklist(['hot', 'new', 'top-day', 'top-week', 'top-month']))
+ }),
async (input): Promise => {
const { platform } = getRequestEvent();
const env = platform?.env;
if (!env || !env.DB) return [];
- return getCombinedFeed(env.DB, input.limit ?? 50);
+ return getCombinedFeed(
+ env.DB,
+ input.limit ?? 50,
+ (input.sort ?? 'hot') as PostSort,
+ input.offset ?? 0
+ );
}
);
diff --git a/src/lib/reddit/server/submissions.remote.ts b/src/lib/reddit/server/submissions.remote.ts
index bbdfa22..f8f6354 100644
--- a/src/lib/reddit/server/submissions.remote.ts
+++ b/src/lib/reddit/server/submissions.remote.ts
@@ -7,14 +7,52 @@ import { error } from '@sveltejs/kit';
import { command, getRequestEvent } from '$app/server';
import * as v from 'valibot';
import * as TID from '@atcute/tid';
+import type { Did, Handle, ResourceUri } from '@atcute/lexicons';
+import { resolveHandle } from '$lib/atproto/methods';
+import { processWebSubmission } from '../bot';
// Accept either an `at://did/app.bsky.feed.post/rkey` URI directly or a
// bsky.app post URL — the latter is what users will usually paste.
-const AT_URI = /^at:\/\/did:[^/]+\/app\.bsky\.feed\.post\/[A-Za-z0-9]+$/;
+const AT_URI = /^at:\/\/(did:[^/]+)\/app\.bsky\.feed\.post\/([A-Za-z0-9]+)$/;
+const BSKY_URL = /^https?:\/\/bsky\.app\/profile\/([^/\s]+)\/post\/([A-Za-z0-9]+)$/i;
+
+/**
+ * Normalize a user-pasted post reference (either at-URI or bsky.app URL)
+ * into a canonical `at://did/app.bsky.feed.post/rkey`. Resolves the handle
+ * via DoH/slingshot when the URL uses a handle instead of a DID.
+ *
+ * Returns null when the input can't be parsed as a Bluesky post reference.
+ */
+async function normalizePostRef(ref: string): Promise {
+ const trimmed = ref.trim();
+
+ const atMatch = trimmed.match(AT_URI);
+ if (atMatch) return trimmed;
+
+ const urlMatch = trimmed.match(BSKY_URL);
+ if (!urlMatch) return null;
+
+ const handleOrDid = urlMatch[1];
+ const rkey = urlMatch[2];
+
+ let did: string;
+ if (handleOrDid.startsWith('did:')) {
+ did = handleOrDid;
+ } else {
+ try {
+ did = await resolveHandle({ handle: handleOrDid as Handle });
+ } catch {
+ return null;
+ }
+ }
+
+ return `at://${did}/app.bsky.feed.post/${rkey}`;
+}
export const createSubmission = command(
v.object({
- postUri: v.pipe(v.string(), v.regex(AT_URI, 'Expected an app.bsky.feed.post AT URI')),
+ // Accepts either an at-URI or a bsky.app post URL; normalized server-side.
+ postRef: v.pipe(v.string(), v.minLength(1), v.maxLength(1024)),
communityDid: v.pipe(
v.string(),
v.regex(/^did:[a-z]+:[a-zA-Z0-9._:%-]+$/, 'Expected a DID')
@@ -25,9 +63,17 @@ export const createSubmission = command(
title: v.optional(v.pipe(v.string(), v.maxLength(600)))
}),
async (input) => {
- const { locals } = getRequestEvent();
+ const { platform, locals } = getRequestEvent();
if (!locals.client || !locals.did) error(401, 'Not authenticated');
+ const postUri = await normalizePostRef(input.postRef);
+ if (!postUri) {
+ error(
+ 400,
+ "Couldn't parse that as a Bluesky post. Paste a bsky.app URL or an at:// URI."
+ );
+ }
+
const rkey = TID.now();
const res = await locals.client.post('com.atproto.repo.createRecord', {
input: {
@@ -36,7 +82,7 @@ export const createSubmission = command(
rkey,
record: {
$type: 'garden.atmo.submission',
- post: input.postUri,
+ post: postUri,
community: input.communityDid,
...(input.title ? { title: input.title } : {}),
createdAt: new Date().toISOString()
@@ -45,6 +91,30 @@ export const createSubmission = command(
});
if (!res.ok) error(res.status, 'Failed to create submission');
- return { uri: res.data.uri };
+
+ // Synchronously turn the submission into a community quote post so the
+ // user doesn't have to wait for the jetstream cron. The drain in
+ // bot.ts is still the canonical path (and handles submissions from
+ // other clients) — this is a best-effort fast path. Dedup in
+ // processWebSubmission keeps the two paths idempotent.
+ const env = platform?.env;
+ let processed = false;
+ let reason: string | undefined;
+ if (env?.DB) {
+ try {
+ const result = await processWebSubmission(env, env.DB, {
+ communityDid: input.communityDid,
+ postUri: postUri as ResourceUri,
+ title: input.title?.trim() ?? '',
+ submitterDid: locals.did as Did
+ });
+ processed = result.ok;
+ if (!result.ok) reason = result.reason;
+ } catch (e) {
+ console.error('[createSubmission] immediate processing failed', e);
+ }
+ }
+
+ return { uri: res.data.uri, processed, reason };
}
);
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 4feeee2..bedf4ba 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,41 +1,97 @@
atmo.garden
+
+
{#if loading}
{:else if feed.length === 0}
- No submissions yet.
+ {sort === 'new' || sort === 'hot' ? 'No submissions yet.' : 'Nothing in this window.'}
{:else}
@@ -47,5 +103,13 @@
/>
{/each}
+ {#if loadingMore}
+
+
+
+ {/if}
+ {#if !hasMore && feed.length > 0}
+
You've reached the end
+ {/if}
{/if}
diff --git a/src/routes/c/[handle]/+page.svelte b/src/routes/c/[handle]/+page.svelte
index 7c1de69..11f76ba 100644
--- a/src/routes/c/[handle]/+page.svelte
+++ b/src/routes/c/[handle]/+page.svelte
@@ -2,28 +2,19 @@
import { untrack, onMount, onDestroy } from 'svelte';
import { page } from '$app/state';
import { Avatar, Button } from '@foxui/core';
- import { Loader2, Check, UserPlus } from '@lucide/svelte';
+ import { Loader2, Check, UserPlus, Plus } from '@lucide/svelte';
import { getCommunity, getCommunityPosts } from '$lib/reddit/server/communities.remote';
import { getQuotedPosts } from '$lib/reddit/server/quoted-posts.remote';
import { followUser, unfollowUser, getProfile } from '$lib/atproto/server/feed.remote';
import { user } from '$lib/atproto/auth.svelte';
import { loginModalState } from '$lib/LoginModal.svelte';
import RedditPostCard from '$lib/reddit/RedditPostCard.svelte';
- import type { PostWithCommunity } from '$lib/reddit/db';
+ import SubmitModal from '$lib/reddit/SubmitModal.svelte';
+ import SortTabs from '$lib/reddit/SortTabs.svelte';
+ import type { PostSort, PostWithCommunity } from '$lib/reddit/db';
type CommunityInfo = Awaited>;
- type Sort = 'hot' | 'new' | 'top-day' | 'top-week' | 'top-month';
- type TopSort = 'top-day' | 'top-week' | 'top-month';
- type PrimaryTab = 'hot' | 'new' | 'top';
-
- const TOP_SORT_LABELS: Record = {
- 'top-day': 'Today',
- 'top-week': 'This week',
- 'top-month': 'This month'
- };
- const TOP_SORT_OPTIONS: TopSort[] = ['top-day', 'top-week', 'top-month'];
-
let loading = $state(true);
let postsLoading = $state(false);
let loadingMore = $state(false);
@@ -32,10 +23,7 @@
let community = $state(null);
let posts = $state([]);
let quoted = $state>({});
- let sort = $state('hot');
- const primaryTab = $derived(
- sort === 'hot' ? 'hot' : sort === 'new' ? 'new' : 'top'
- );
+ let sort = $state('hot');
// Follow state — populated after the community loads, if the user is
// signed in. `followUri` is the AT-URI of the user's follow record
@@ -44,7 +32,17 @@
let joinLoading = $state(false);
const isFollowing = $derived(followUri !== null);
- async function loadPosts(handle: string, nextSort: Sort) {
+ let submitOpen = $state(false);
+
+ function onSubmitClick() {
+ if (!user.did) {
+ loginModalState.open = true;
+ return;
+ }
+ submitOpen = true;
+ }
+
+ async function loadPosts(handle: string, nextSort: PostSort) {
postsLoading = true;
hasMore = true;
try {
@@ -144,18 +142,11 @@
}
}
- function onSortClick(next: Sort) {
- if (next === sort || !community) return;
- sort = next;
+ function onSortChange(next: PostSort) {
+ if (!community) return;
loadPosts(community.handle, next);
}
- function onPrimaryTabClick(tab: PrimaryTab) {
- if (tab === primaryTab) return;
- const next: Sort = tab === 'hot' ? 'hot' : tab === 'new' ? 'new' : 'top-day';
- onSortClick(next);
- }
-
async function onJoinClick() {
if (!community) return;
if (!user.did) {
@@ -185,7 +176,7 @@
});
-