From b74a8886b02e4bb035b5c0cb1bf073bf2bc58988 Mon Sep 17 00:00:00 2001
From: Hong Minhee
Date: Tue, 25 Jun 2024 22:36:24 +0900
Subject: [PATCH] Pinned posts
---
.vscode/settings.json | 2 +
README.md | 2 +-
bun.lockb | Bin 148585 -> 148585 bytes
drizzle/0024_pinned_posts.sql | 21 +
drizzle/0025_accounts.featured_url.sql | 1 +
drizzle/meta/0024_snapshot.json | 1266 +++++++++++++++++++++++
drizzle/meta/0025_snapshot.json | 1272 ++++++++++++++++++++++++
drizzle/meta/_journal.json | 14 +
package.json | 2 +-
src/accounts.tsx | 1 +
src/api/v1/accounts.ts | 12 +-
src/api/v1/statuses.ts | 113 ++-
src/components/Post.tsx | 6 +-
src/entities/status.ts | 6 +
src/federation/account.ts | 23 +
src/federation/collection.ts | 33 +
src/federation/index.ts | 75 ++
src/profile.tsx | 54 +-
src/schema.ts | 119 ++-
19 files changed, 2982 insertions(+), 40 deletions(-)
create mode 100644 drizzle/0024_pinned_posts.sql
create mode 100644 drizzle/0025_accounts.featured_url.sql
create mode 100644 drizzle/meta/0024_snapshot.json
create mode 100644 drizzle/meta/0025_snapshot.json
create mode 100644 src/federation/collection.ts
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7d9ffca..adb487a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -26,6 +26,7 @@
},
"cSpell.words": [
"activitypub",
+ "bigserial",
"biomejs",
"blurhash",
"bunx",
@@ -36,6 +37,7 @@
"fedi",
"fedify",
"fediverse",
+ "fkey",
"hono",
"htmls",
"ilike",
diff --git a/README.md b/README.md
index 01e9b2d..4048ecf 100644
--- a/README.md
+++ b/README.md
@@ -123,7 +123,7 @@ Current features and roadmap
- [x] View posts
- [x] Post visibility
- [x] Post language
-- [ ] Pinned posts
+- [x] Pinned posts
- [x] Mentions
- [x] Hashtags
- [x] Media attachments
diff --git a/bun.lockb b/bun.lockb
index 9548f1ef3a368bed525bb3faf58ae2211307b6e6..ad3d52afe0bc722faeb640d2a5efffee0506f392 100755
GIT binary patch
delta 155
zcmV;M0A&B^hzaS436L%zf+mzHYE&9GZm){t-d2?+z&GRZsa(T-X=l-5G-IhTu}E!ZJgpedl$IMu4=q)$dP4BD;+S_Jks
zbrwp`krr1p|l;};L|Dqq8iiS37^*=-@PjZ1knu}Vs9Xw^gzM{YRIv{{bqOP8R|a
Jw~qP&+etl%Ov(TN
diff --git a/drizzle/0024_pinned_posts.sql b/drizzle/0024_pinned_posts.sql
new file mode 100644
index 0000000..46a94ff
--- /dev/null
+++ b/drizzle/0024_pinned_posts.sql
@@ -0,0 +1,21 @@
+CREATE TABLE IF NOT EXISTS "pinned_posts" (
+ "index" bigserial PRIMARY KEY NOT NULL,
+ "post_id" uuid NOT NULL,
+ "account_id" uuid NOT NULL,
+ "created" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "pinned_posts_post_id_account_id_unique" UNIQUE("post_id","account_id")
+);
+--> statement-breakpoint
+ALTER TABLE "posts" ADD CONSTRAINT "posts_id_actor_id_unique" UNIQUE("id", "actor_id");
+--> statement-breakpoint
+DO $$ BEGIN
+ ALTER TABLE "pinned_posts" ADD CONSTRAINT "pinned_posts_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+--> statement-breakpoint
+DO $$ BEGIN
+ ALTER TABLE "pinned_posts" ADD CONSTRAINT "pinned_posts_post_id_account_id_posts_id_actor_id_fk" FOREIGN KEY ("post_id","account_id") REFERENCES "public"."posts"("id","actor_id") ON DELETE cascade ON UPDATE no action;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
diff --git a/drizzle/0025_accounts.featured_url.sql b/drizzle/0025_accounts.featured_url.sql
new file mode 100644
index 0000000..5517e4d
--- /dev/null
+++ b/drizzle/0025_accounts.featured_url.sql
@@ -0,0 +1 @@
+ALTER TABLE "accounts" ADD COLUMN "featured_url" text;
\ No newline at end of file
diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json
new file mode 100644
index 0000000..80c68eb
--- /dev/null
+++ b/drizzle/meta/0024_snapshot.json
@@ -0,0 +1,1266 @@
+{
+ "id": "5124b513-c942-4658-87bd-48ffb544249e",
+ "prevId": "e5cbde0d-8c7a-4495-bb42-817987717f2f",
+ "version": "6",
+ "dialect": "postgresql",
+ "tables": {
+ "public.access_tokens": {
+ "name": "access_tokens",
+ "schema": "",
+ "columns": {
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_owner_id": {
+ "name": "account_owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grant_type": {
+ "name": "grant_type",
+ "type": "grant_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'authorization_code'"
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "scope[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "access_tokens_application_id_applications_id_fk": {
+ "name": "access_tokens_application_id_applications_id_fk",
+ "tableFrom": "access_tokens",
+ "tableTo": "applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "access_tokens_account_owner_id_account_owners_id_fk": {
+ "name": "access_tokens_account_owner_id_account_owners_id_fk",
+ "tableFrom": "access_tokens",
+ "tableTo": "account_owners",
+ "columnsFrom": [
+ "account_owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.account_owners": {
+ "name": "account_owners",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "handle": {
+ "name": "handle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rsa_private_key_jwk": {
+ "name": "rsa_private_key_jwk",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rsa_public_key_jwk": {
+ "name": "rsa_public_key_jwk",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ed25519_private_key_jwk": {
+ "name": "ed25519_private_key_jwk",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ed25519_public_key_jwk": {
+ "name": "ed25519_public_key_jwk",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fields": {
+ "name": "fields",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::json"
+ },
+ "bio": {
+ "name": "bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "followed_tags": {
+ "name": "followed_tags",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": []
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "post_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'public'"
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_owners_id_accounts_id_fk": {
+ "name": "account_owners_id_accounts_id_fk",
+ "tableFrom": "account_owners",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "account_owners_handle_unique": {
+ "name": "account_owners_handle_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "handle"
+ ]
+ }
+ }
+ },
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "iri": {
+ "name": "iri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "account_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "handle": {
+ "name": "handle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bio_html": {
+ "name": "bio_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "inbox_url": {
+ "name": "inbox_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "followers_url": {
+ "name": "followers_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "shared_inbox_url": {
+ "name": "shared_inbox_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "following_count": {
+ "name": "following_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "followers_count": {
+ "name": "followers_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "posts_count": {
+ "name": "posts_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "field_htmls": {
+ "name": "field_htmls",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::json"
+ },
+ "sensitive": {
+ "name": "sensitive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published": {
+ "name": "published",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated": {
+ "name": "updated",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "accounts_iri_unique": {
+ "name": "accounts_iri_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "iri"
+ ]
+ },
+ "accounts_handle_unique": {
+ "name": "accounts_handle_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "handle"
+ ]
+ }
+ }
+ },
+ "public.applications": {
+ "name": "applications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "scope[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "website": {
+ "name": "website",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_secret": {
+ "name": "client_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "applications_client_id_unique": {
+ "name": "applications_client_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "client_id"
+ ]
+ }
+ }
+ },
+ "public.bookmarks": {
+ "name": "bookmarks",
+ "schema": "",
+ "columns": {
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_owner_id": {
+ "name": "account_owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarks_post_id_posts_id_fk": {
+ "name": "bookmarks_post_id_posts_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "post_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarks_account_owner_id_account_owners_id_fk": {
+ "name": "bookmarks_account_owner_id_account_owners_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "account_owners",
+ "columnsFrom": [
+ "account_owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarks_post_id_account_owner_id_pk": {
+ "name": "bookmarks_post_id_account_owner_id_pk",
+ "columns": [
+ "post_id",
+ "account_owner_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "email": {
+ "name": "email",
+ "type": "varchar(254)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.follows": {
+ "name": "follows",
+ "schema": "",
+ "columns": {
+ "iri": {
+ "name": "iri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "following_id": {
+ "name": "following_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "follower_id": {
+ "name": "follower_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "shares": {
+ "name": "shares",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "notify": {
+ "name": "notify",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "languages": {
+ "name": "languages",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "approved": {
+ "name": "approved",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "follows_following_id_accounts_id_fk": {
+ "name": "follows_following_id_accounts_id_fk",
+ "tableFrom": "follows",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "following_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "follows_follower_id_accounts_id_fk": {
+ "name": "follows_follower_id_accounts_id_fk",
+ "tableFrom": "follows",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "follower_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "follows_following_id_follower_id_pk": {
+ "name": "follows_following_id_follower_id_pk",
+ "columns": [
+ "following_id",
+ "follower_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "follows_iri_unique": {
+ "name": "follows_iri_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "iri"
+ ]
+ }
+ }
+ },
+ "public.likes": {
+ "name": "likes",
+ "schema": "",
+ "columns": {
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "likes_post_id_posts_id_fk": {
+ "name": "likes_post_id_posts_id_fk",
+ "tableFrom": "likes",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "post_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "likes_account_id_accounts_id_fk": {
+ "name": "likes_account_id_accounts_id_fk",
+ "tableFrom": "likes",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "account_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "likes_post_id_account_id_pk": {
+ "name": "likes_post_id_account_id_pk",
+ "columns": [
+ "post_id",
+ "account_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "public.markers": {
+ "name": "markers",
+ "schema": "",
+ "columns": {
+ "account_owner_id": {
+ "name": "account_owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "marker_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_read_id": {
+ "name": "last_read_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "updated": {
+ "name": "updated",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "markers_account_owner_id_account_owners_id_fk": {
+ "name": "markers_account_owner_id_account_owners_id_fk",
+ "tableFrom": "markers",
+ "tableTo": "account_owners",
+ "columnsFrom": [
+ "account_owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "markers_account_owner_id_type_pk": {
+ "name": "markers_account_owner_id_type_pk",
+ "columns": [
+ "account_owner_id",
+ "type"
+ ]
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "public.media": {
+ "name": "media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "thumbnail_type": {
+ "name": "thumbnail_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thumbnail_url": {
+ "name": "thumbnail_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thumbnail_width": {
+ "name": "thumbnail_width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thumbnail_height": {
+ "name": "thumbnail_height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_post_id_posts_id_fk": {
+ "name": "media_post_id_posts_id_fk",
+ "tableFrom": "media",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "post_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.mentions": {
+ "name": "mentions",
+ "schema": "",
+ "columns": {
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mentions_post_id_posts_id_fk": {
+ "name": "mentions_post_id_posts_id_fk",
+ "tableFrom": "mentions",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "post_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mentions_account_id_accounts_id_fk": {
+ "name": "mentions_account_id_accounts_id_fk",
+ "tableFrom": "mentions",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "account_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mentions_post_id_account_id_pk": {
+ "name": "mentions_post_id_account_id_pk",
+ "columns": [
+ "post_id",
+ "account_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "public.pinned_posts": {
+ "name": "pinned_posts",
+ "schema": "",
+ "columns": {
+ "index": {
+ "name": "index",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "pinned_posts_account_id_accounts_id_fk": {
+ "name": "pinned_posts_account_id_accounts_id_fk",
+ "tableFrom": "pinned_posts",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "account_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pinned_posts_post_id_account_id_posts_id_actor_id_fk": {
+ "name": "pinned_posts_post_id_account_id_posts_id_actor_id_fk",
+ "tableFrom": "pinned_posts",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "post_id",
+ "account_id"
+ ],
+ "columnsTo": [
+ "id",
+ "actor_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "pinned_posts_post_id_account_id_unique": {
+ "name": "pinned_posts_post_id_account_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "post_id",
+ "account_id"
+ ]
+ }
+ }
+ },
+ "public.posts": {
+ "name": "posts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "iri": {
+ "name": "iri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "post_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reply_target_id": {
+ "name": "reply_target_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sharing_id": {
+ "name": "sharing_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "post_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary_html": {
+ "name": "summary_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content_html": {
+ "name": "content_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "sensitive": {
+ "name": "sensitive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "preview_card": {
+ "name": "preview_card",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replies_count": {
+ "name": "replies_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "shares_count": {
+ "name": "shares_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "likes_count": {
+ "name": "likes_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "published": {
+ "name": "published",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated": {
+ "name": "updated",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "posts_actor_id_accounts_id_fk": {
+ "name": "posts_actor_id_accounts_id_fk",
+ "tableFrom": "posts",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "posts_application_id_applications_id_fk": {
+ "name": "posts_application_id_applications_id_fk",
+ "tableFrom": "posts",
+ "tableTo": "applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "posts_reply_target_id_posts_id_fk": {
+ "name": "posts_reply_target_id_posts_id_fk",
+ "tableFrom": "posts",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "reply_target_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "posts_sharing_id_posts_id_fk": {
+ "name": "posts_sharing_id_posts_id_fk",
+ "tableFrom": "posts",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "sharing_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "posts_iri_unique": {
+ "name": "posts_iri_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "iri"
+ ]
+ },
+ "posts_id_actor_id_unique": {
+ "name": "posts_id_actor_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id",
+ "actor_id"
+ ]
+ }
+ }
+ }
+ },
+ "enums": {
+ "public.account_type": {
+ "name": "account_type",
+ "schema": "public",
+ "values": [
+ "Application",
+ "Group",
+ "Organization",
+ "Person",
+ "Service"
+ ]
+ },
+ "public.grant_type": {
+ "name": "grant_type",
+ "schema": "public",
+ "values": [
+ "authorization_code",
+ "client_credentials"
+ ]
+ },
+ "public.marker_type": {
+ "name": "marker_type",
+ "schema": "public",
+ "values": [
+ "notifications",
+ "home"
+ ]
+ },
+ "public.post_type": {
+ "name": "post_type",
+ "schema": "public",
+ "values": [
+ "Article",
+ "Note"
+ ]
+ },
+ "public.post_visibility": {
+ "name": "post_visibility",
+ "schema": "public",
+ "values": [
+ "public",
+ "unlisted",
+ "private",
+ "direct"
+ ]
+ },
+ "public.scope": {
+ "name": "scope",
+ "schema": "public",
+ "values": [
+ "read",
+ "read:accounts",
+ "read:blocks",
+ "read:bookmarks",
+ "read:favourites",
+ "read:filters",
+ "read:follows",
+ "read:lists",
+ "read:mutes",
+ "read:notifications",
+ "read:search",
+ "read:statuses",
+ "write",
+ "write:accounts",
+ "write:blocks",
+ "write:bookmarks",
+ "write:conversations",
+ "write:favourites",
+ "write:filters",
+ "write:follows",
+ "write:lists",
+ "write:media",
+ "write:mutes",
+ "write:notifications",
+ "write:reports",
+ "write:statuses",
+ "follow",
+ "push"
+ ]
+ }
+ },
+ "schemas": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0025_snapshot.json b/drizzle/meta/0025_snapshot.json
new file mode 100644
index 0000000..847124f
--- /dev/null
+++ b/drizzle/meta/0025_snapshot.json
@@ -0,0 +1,1272 @@
+{
+ "id": "c84f7161-a0c6-4bb4-adc9-82576a44bf62",
+ "prevId": "5124b513-c942-4658-87bd-48ffb544249e",
+ "version": "6",
+ "dialect": "postgresql",
+ "tables": {
+ "public.access_tokens": {
+ "name": "access_tokens",
+ "schema": "",
+ "columns": {
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_owner_id": {
+ "name": "account_owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grant_type": {
+ "name": "grant_type",
+ "type": "grant_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'authorization_code'"
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "scope[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "access_tokens_application_id_applications_id_fk": {
+ "name": "access_tokens_application_id_applications_id_fk",
+ "tableFrom": "access_tokens",
+ "tableTo": "applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "access_tokens_account_owner_id_account_owners_id_fk": {
+ "name": "access_tokens_account_owner_id_account_owners_id_fk",
+ "tableFrom": "access_tokens",
+ "tableTo": "account_owners",
+ "columnsFrom": [
+ "account_owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.account_owners": {
+ "name": "account_owners",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "handle": {
+ "name": "handle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rsa_private_key_jwk": {
+ "name": "rsa_private_key_jwk",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rsa_public_key_jwk": {
+ "name": "rsa_public_key_jwk",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ed25519_private_key_jwk": {
+ "name": "ed25519_private_key_jwk",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ed25519_public_key_jwk": {
+ "name": "ed25519_public_key_jwk",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fields": {
+ "name": "fields",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::json"
+ },
+ "bio": {
+ "name": "bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "followed_tags": {
+ "name": "followed_tags",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": []
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "post_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'public'"
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_owners_id_accounts_id_fk": {
+ "name": "account_owners_id_accounts_id_fk",
+ "tableFrom": "account_owners",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "account_owners_handle_unique": {
+ "name": "account_owners_handle_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "handle"
+ ]
+ }
+ }
+ },
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "iri": {
+ "name": "iri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "account_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "handle": {
+ "name": "handle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bio_html": {
+ "name": "bio_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "protected": {
+ "name": "protected",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "inbox_url": {
+ "name": "inbox_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "followers_url": {
+ "name": "followers_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "shared_inbox_url": {
+ "name": "shared_inbox_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "featured_url": {
+ "name": "featured_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "following_count": {
+ "name": "following_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "followers_count": {
+ "name": "followers_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "posts_count": {
+ "name": "posts_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "field_htmls": {
+ "name": "field_htmls",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::json"
+ },
+ "sensitive": {
+ "name": "sensitive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published": {
+ "name": "published",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated": {
+ "name": "updated",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "accounts_iri_unique": {
+ "name": "accounts_iri_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "iri"
+ ]
+ },
+ "accounts_handle_unique": {
+ "name": "accounts_handle_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "handle"
+ ]
+ }
+ }
+ },
+ "public.applications": {
+ "name": "applications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "scope[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "website": {
+ "name": "website",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_secret": {
+ "name": "client_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "applications_client_id_unique": {
+ "name": "applications_client_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "client_id"
+ ]
+ }
+ }
+ },
+ "public.bookmarks": {
+ "name": "bookmarks",
+ "schema": "",
+ "columns": {
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_owner_id": {
+ "name": "account_owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarks_post_id_posts_id_fk": {
+ "name": "bookmarks_post_id_posts_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "post_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarks_account_owner_id_account_owners_id_fk": {
+ "name": "bookmarks_account_owner_id_account_owners_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "account_owners",
+ "columnsFrom": [
+ "account_owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarks_post_id_account_owner_id_pk": {
+ "name": "bookmarks_post_id_account_owner_id_pk",
+ "columns": [
+ "post_id",
+ "account_owner_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "email": {
+ "name": "email",
+ "type": "varchar(254)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.follows": {
+ "name": "follows",
+ "schema": "",
+ "columns": {
+ "iri": {
+ "name": "iri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "following_id": {
+ "name": "following_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "follower_id": {
+ "name": "follower_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "shares": {
+ "name": "shares",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "notify": {
+ "name": "notify",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "languages": {
+ "name": "languages",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "approved": {
+ "name": "approved",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "follows_following_id_accounts_id_fk": {
+ "name": "follows_following_id_accounts_id_fk",
+ "tableFrom": "follows",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "following_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "follows_follower_id_accounts_id_fk": {
+ "name": "follows_follower_id_accounts_id_fk",
+ "tableFrom": "follows",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "follower_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "follows_following_id_follower_id_pk": {
+ "name": "follows_following_id_follower_id_pk",
+ "columns": [
+ "following_id",
+ "follower_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "follows_iri_unique": {
+ "name": "follows_iri_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "iri"
+ ]
+ }
+ }
+ },
+ "public.likes": {
+ "name": "likes",
+ "schema": "",
+ "columns": {
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "likes_post_id_posts_id_fk": {
+ "name": "likes_post_id_posts_id_fk",
+ "tableFrom": "likes",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "post_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "likes_account_id_accounts_id_fk": {
+ "name": "likes_account_id_accounts_id_fk",
+ "tableFrom": "likes",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "account_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "likes_post_id_account_id_pk": {
+ "name": "likes_post_id_account_id_pk",
+ "columns": [
+ "post_id",
+ "account_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "public.markers": {
+ "name": "markers",
+ "schema": "",
+ "columns": {
+ "account_owner_id": {
+ "name": "account_owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "marker_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_read_id": {
+ "name": "last_read_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "updated": {
+ "name": "updated",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "markers_account_owner_id_account_owners_id_fk": {
+ "name": "markers_account_owner_id_account_owners_id_fk",
+ "tableFrom": "markers",
+ "tableTo": "account_owners",
+ "columnsFrom": [
+ "account_owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "markers_account_owner_id_type_pk": {
+ "name": "markers_account_owner_id_type_pk",
+ "columns": [
+ "account_owner_id",
+ "type"
+ ]
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "public.media": {
+ "name": "media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "thumbnail_type": {
+ "name": "thumbnail_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thumbnail_url": {
+ "name": "thumbnail_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thumbnail_width": {
+ "name": "thumbnail_width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thumbnail_height": {
+ "name": "thumbnail_height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_post_id_posts_id_fk": {
+ "name": "media_post_id_posts_id_fk",
+ "tableFrom": "media",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "post_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.mentions": {
+ "name": "mentions",
+ "schema": "",
+ "columns": {
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mentions_post_id_posts_id_fk": {
+ "name": "mentions_post_id_posts_id_fk",
+ "tableFrom": "mentions",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "post_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mentions_account_id_accounts_id_fk": {
+ "name": "mentions_account_id_accounts_id_fk",
+ "tableFrom": "mentions",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "account_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mentions_post_id_account_id_pk": {
+ "name": "mentions_post_id_account_id_pk",
+ "columns": [
+ "post_id",
+ "account_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "public.pinned_posts": {
+ "name": "pinned_posts",
+ "schema": "",
+ "columns": {
+ "index": {
+ "name": "index",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "post_id": {
+ "name": "post_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created": {
+ "name": "created",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "pinned_posts_account_id_accounts_id_fk": {
+ "name": "pinned_posts_account_id_accounts_id_fk",
+ "tableFrom": "pinned_posts",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "account_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pinned_posts_post_id_account_id_posts_id_actor_id_fk": {
+ "name": "pinned_posts_post_id_account_id_posts_id_actor_id_fk",
+ "tableFrom": "pinned_posts",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "post_id",
+ "account_id"
+ ],
+ "columnsTo": [
+ "id",
+ "actor_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "pinned_posts_post_id_account_id_unique": {
+ "name": "pinned_posts_post_id_account_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "post_id",
+ "account_id"
+ ]
+ }
+ }
+ },
+ "public.posts": {
+ "name": "posts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "iri": {
+ "name": "iri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "post_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reply_target_id": {
+ "name": "reply_target_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sharing_id": {
+ "name": "sharing_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "post_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary_html": {
+ "name": "summary_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content_html": {
+ "name": "content_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "sensitive": {
+ "name": "sensitive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "preview_card": {
+ "name": "preview_card",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replies_count": {
+ "name": "replies_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "shares_count": {
+ "name": "shares_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "likes_count": {
+ "name": "likes_count",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "published": {
+ "name": "published",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated": {
+ "name": "updated",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "posts_actor_id_accounts_id_fk": {
+ "name": "posts_actor_id_accounts_id_fk",
+ "tableFrom": "posts",
+ "tableTo": "accounts",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "posts_application_id_applications_id_fk": {
+ "name": "posts_application_id_applications_id_fk",
+ "tableFrom": "posts",
+ "tableTo": "applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "posts_reply_target_id_posts_id_fk": {
+ "name": "posts_reply_target_id_posts_id_fk",
+ "tableFrom": "posts",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "reply_target_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "posts_sharing_id_posts_id_fk": {
+ "name": "posts_sharing_id_posts_id_fk",
+ "tableFrom": "posts",
+ "tableTo": "posts",
+ "columnsFrom": [
+ "sharing_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "posts_iri_unique": {
+ "name": "posts_iri_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "iri"
+ ]
+ },
+ "posts_id_actor_id_unique": {
+ "name": "posts_id_actor_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id",
+ "actor_id"
+ ]
+ }
+ }
+ }
+ },
+ "enums": {
+ "public.account_type": {
+ "name": "account_type",
+ "schema": "public",
+ "values": [
+ "Application",
+ "Group",
+ "Organization",
+ "Person",
+ "Service"
+ ]
+ },
+ "public.grant_type": {
+ "name": "grant_type",
+ "schema": "public",
+ "values": [
+ "authorization_code",
+ "client_credentials"
+ ]
+ },
+ "public.marker_type": {
+ "name": "marker_type",
+ "schema": "public",
+ "values": [
+ "notifications",
+ "home"
+ ]
+ },
+ "public.post_type": {
+ "name": "post_type",
+ "schema": "public",
+ "values": [
+ "Article",
+ "Note"
+ ]
+ },
+ "public.post_visibility": {
+ "name": "post_visibility",
+ "schema": "public",
+ "values": [
+ "public",
+ "unlisted",
+ "private",
+ "direct"
+ ]
+ },
+ "public.scope": {
+ "name": "scope",
+ "schema": "public",
+ "values": [
+ "read",
+ "read:accounts",
+ "read:blocks",
+ "read:bookmarks",
+ "read:favourites",
+ "read:filters",
+ "read:follows",
+ "read:lists",
+ "read:mutes",
+ "read:notifications",
+ "read:search",
+ "read:statuses",
+ "write",
+ "write:accounts",
+ "write:blocks",
+ "write:bookmarks",
+ "write:conversations",
+ "write:favourites",
+ "write:filters",
+ "write:follows",
+ "write:lists",
+ "write:media",
+ "write:mutes",
+ "write:notifications",
+ "write:reports",
+ "write:statuses",
+ "follow",
+ "push"
+ ]
+ }
+ },
+ "schemas": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 98a8858..8b888d7 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -169,6 +169,20 @@
"when": 1718518714023,
"tag": "0023_ed25519-keys",
"breakpoints": true
+ },
+ {
+ "idx": 24,
+ "version": "6",
+ "when": 1719304863977,
+ "tag": "0024_pinned_posts",
+ "breakpoints": true
+ },
+ {
+ "idx": 25,
+ "version": "6",
+ "when": 1719307716065,
+ "tag": "0025_accounts.featured_url",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/package.json b/package.json
index f182b48..0889c7a 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.577.0",
"@aws-sdk/credential-providers": "^3.577.0",
- "@fedify/fedify": "^0.11.0-dev.240",
+ "@fedify/fedify": "^0.11.0-dev.243",
"@fedify/markdown-it-hashtag": "0.2.0",
"@fedify/markdown-it-mention": "^0.1.1",
"@fedify/redis": "^0.1.1",
diff --git a/src/accounts.tsx b/src/accounts.tsx
index 5bfdea0..ab84d20 100644
--- a/src/accounts.tsx
+++ b/src/accounts.tsx
@@ -111,6 +111,7 @@ app.post("/", async (c) => {
inboxUrl: fedCtx.getInboxUri(username).href,
followersUrl: fedCtx.getFollowersUri(username).href,
sharedInboxUrl: fedCtx.getInboxUri().href,
+ featuredUrl: fedCtx.getFeaturedUri(username).href,
published: new Date(),
})
.returning();
diff --git a/src/api/v1/accounts.ts b/src/api/v1/accounts.ts
index 81b83ef..e0368ea 100644
--- a/src/api/v1/accounts.ts
+++ b/src/api/v1/accounts.ts
@@ -34,6 +34,7 @@ import {
accounts,
follows,
mentions,
+ pinnedPosts,
posts,
} from "../../schema";
import { search } from "../../search";
@@ -469,7 +470,7 @@ app.get(
);
}
const query = c.req.valid("query");
- if (query.pinned || query.only_media) {
+ if (query.only_media) {
return c.json([]); // FIXME
}
const following = await db
@@ -497,6 +498,15 @@ app.get(
),
),
),
+ query.pinned === "true"
+ ? inArray(
+ posts.id,
+ db
+ .select({ id: pinnedPosts.postId })
+ .from(pinnedPosts)
+ .where(eq(pinnedPosts.accountId, id)),
+ )
+ : undefined,
query.exclude_replies === "true"
? isNull(posts.replyTargetId)
: undefined,
diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts
index 5c919f1..15bd9d3 100644
--- a/src/api/v1/statuses.ts
+++ b/src/api/v1/statuses.ts
@@ -1,4 +1,4 @@
-import { Note, Undo } from "@fedify/fedify";
+import { Add, Note, Remove, Undo } from "@fedify/fedify";
import * as vocab from "@fedify/fedify/vocab";
import { zValidator } from "@hono/zod-validator";
import { and, eq, isNull, sql } from "drizzle-orm";
@@ -20,11 +20,13 @@ import {
type Like,
type NewBookmark,
type NewLike,
+ type NewPinnedPost,
type NewPost,
bookmarks,
likes,
media,
mentions,
+ pinnedPosts,
posts,
} from "../../schema";
import search from "../../search";
@@ -736,4 +738,113 @@ app.post(
},
);
+app.post(
+ "/:id/pin",
+ tokenRequired,
+ scopeRequired(["write:accounts"]),
+ async (c) => {
+ const owner = c.get("token").accountOwner;
+ if (owner == null) {
+ return c.json(
+ { error: "This method requires an authenticated user" },
+ 422,
+ );
+ }
+ const postId = c.req.param("id");
+ const post = await db.query.posts.findFirst({
+ where: eq(posts.id, postId),
+ });
+ if (post == null) {
+ return c.json({ error: "Record not found" }, 404);
+ }
+ if (post.accountId !== owner.id) {
+ return c.json(
+ { error: "Validation failed: Someone else's post cannot be pinned" },
+ 422,
+ );
+ }
+ const result = await db
+ .insert(pinnedPosts)
+ .values({
+ postId,
+ accountId: owner.id,
+ } satisfies NewPinnedPost)
+ .returning();
+ const fedCtx = federation.createContext(c.req.raw, undefined);
+ await fedCtx.sendActivity(
+ owner,
+ "followers",
+ new Add({
+ id: new URL(
+ `#add/${result[0].index}`,
+ fedCtx.getFeaturedUri(owner.handle),
+ ),
+ actor: new URL(owner.account.iri),
+ object: new URL(post.iri),
+ target: fedCtx.getFeaturedUri(owner.handle),
+ }),
+ {
+ preferSharedInbox: true,
+ excludeBaseUris: [new URL(c.req.url)],
+ },
+ );
+ const resultPost = await db.query.posts.findFirst({
+ where: eq(posts.id, postId),
+ with: getPostRelations(owner.id),
+ });
+ return c.json(serializePost(resultPost!, owner, c.req.url));
+ },
+);
+
+app.post(
+ "/:id/unpin",
+ tokenRequired,
+ scopeRequired(["write:accounts"]),
+ async (c) => {
+ const owner = c.get("token").accountOwner;
+ if (owner == null) {
+ return c.json(
+ { error: "This method requires an authenticated user" },
+ 422,
+ );
+ }
+ const postId = c.req.param("id");
+ const result = await db
+ .delete(pinnedPosts)
+ .where(
+ and(
+ eq(pinnedPosts.postId, postId),
+ eq(pinnedPosts.accountId, owner.id),
+ ),
+ )
+ .returning();
+ if (result.length < 1) {
+ return c.json({ error: "Record not found" }, 404);
+ }
+ const post = await db.query.posts.findFirst({
+ where: eq(posts.id, postId),
+ with: getPostRelations(owner.id),
+ });
+ const fedCtx = federation.createContext(c.req.raw, undefined);
+ await fedCtx.sendActivity(
+ owner,
+ "followers",
+ new Remove({
+ id: new URL(
+ `#remove/${result[0].index}`,
+ fedCtx.getFeaturedUri(owner.handle),
+ ),
+ actor: new URL(owner.account.iri),
+ object: new URL(post!.iri),
+ target: fedCtx.getFeaturedUri(owner.handle),
+ }),
+ {
+ preferSharedInbox: true,
+ excludeBaseUris: [new URL(c.req.url)],
+ },
+ );
+ return c.json(serializePost(post!, owner, c.req.url));
+ },
+);
+
export default app;
diff --git a/src/components/Post.tsx b/src/components/Post.tsx
index 87b75b2..2534263 100644
--- a/src/components/Post.tsx
+++ b/src/components/Post.tsx
@@ -14,14 +14,15 @@ export interface PostProps {
| null;
replyTarget: (DbPost & { account: Account }) | null;
};
+ pinned?: boolean;
}
-export const Post: FC = ({ post }) => {
+export const Post: FC = ({ post, pinned }) => {
if (post.sharing != null)
return ;
const account = post.account;
return (
-
+
{account.avatarUrl && (
@@ -74,6 +75,7 @@ export const Post: FC = ({ post }) => {
+ {pinned ? · Pinned : ""}
diff --git a/src/entities/status.ts b/src/entities/status.ts
index d7e872c..54e962d 100644
--- a/src/entities/status.ts
+++ b/src/entities/status.ts
@@ -8,6 +8,7 @@ import {
type Like,
type Medium,
type Mention,
+ type PinnedPost,
type Post,
bookmarks,
likes,
@@ -32,6 +33,7 @@ export function getPostRelations(ownerId: string) {
likes: { where: eq(likes.accountId, ownerId) },
shares: { where: eq(posts.accountId, ownerId) },
bookmarks: { where: eq(bookmarks.accountOwnerId, ownerId) },
+ pin: true,
},
},
media: true,
@@ -39,6 +41,7 @@ export function getPostRelations(ownerId: string) {
likes: { where: eq(likes.accountId, ownerId) },
shares: { where: eq(posts.accountId, ownerId) },
bookmarks: { where: eq(bookmarks.accountOwnerId, ownerId) },
+ pin: true,
} as const;
}
@@ -59,6 +62,7 @@ export function serializePost(
likes: Like[];
shares: Post[];
bookmarks: Bookmark[];
+ pin: PinnedPost | null;
})
| null;
media: Medium[];
@@ -68,6 +72,7 @@ export function serializePost(
likes: Like[];
shares: Post[];
bookmarks: Bookmark[];
+ pin: PinnedPost | null;
},
currentAccountOwner: { id: string },
baseUrl: URL | string,
@@ -97,6 +102,7 @@ export function serializePost(
bookmarked: post.bookmarks.some(
(bookmark) => bookmark.accountOwnerId === currentAccountOwner.id,
),
+ pinned: post.pin != null && post.pin.accountId === currentAccountOwner.id,
content: post.contentHtml ?? "",
reblog:
post.sharing == null
diff --git a/src/federation/account.ts b/src/federation/account.ts
index 5c59f8e..63b8dba 100644
--- a/src/federation/account.ts
+++ b/src/federation/account.ts
@@ -1,7 +1,9 @@
import {
type Actor,
+ Article,
type DocumentLoader,
Link,
+ Note,
PropertyValue,
getActorHandle,
getActorTypeName,
@@ -22,7 +24,10 @@ import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
import type MeiliSearch from "meilisearch";
import { uuidv7 } from "uuidv7-js";
import * as schema from "../schema";
+import type { NewPinnedPost, Post } from "../schema";
+import { iterateCollection } from "./collection";
import { toDate } from "./date";
+import { persistPost } from "./post";
export async function persistAccount(
db: PgDatabase<
@@ -65,6 +70,23 @@ export async function persistAccount(
fieldHtmls[attachment.name.toString()] = attachment.value.toString();
}
}
+ const featuredCollection = await actor.getFeatured(opts);
+ if (featuredCollection != null) {
+ const posts: Post[] = [];
+ for await (const item of iterateCollection(featuredCollection, opts)) {
+ if (item instanceof Note || item instanceof Article) {
+ const post = await persistPost(db, search, item, options);
+ if (post == null) continue;
+ posts.unshift(post);
+ }
+ }
+ for (const post of posts) {
+ await db.insert(schema.pinnedPosts).values({
+ postId: post.id,
+ accountId: post.accountId,
+ } satisfies NewPinnedPost);
+ }
+ }
const values: Omit = {
type: getActorTypeName(actor),
name: actor?.name?.toString() ?? actor?.preferredUsername?.toString() ?? "",
@@ -79,6 +101,7 @@ export async function persistAccount(
inboxUrl: actor.inboxId.href,
followersUrl: followers?.id?.href,
sharedInboxUrl: actor.endpoints?.sharedInbox?.href,
+ featuredUrl: actor.featuredId?.href,
followingCount: (await actor.getFollowing(opts))?.totalItems ?? 0,
followersCount: followers?.totalItems ?? 0,
postsCount: (await actor.getOutbox(opts))?.totalItems ?? 0,
diff --git a/src/federation/collection.ts b/src/federation/collection.ts
new file mode 100644
index 0000000..359d78f
--- /dev/null
+++ b/src/federation/collection.ts
@@ -0,0 +1,33 @@
+import {
+ type Object as APObject,
+ type Collection,
+ type DocumentLoader,
+ Link,
+} from "@fedify/fedify";
+
+export interface IterateCollectionOptions {
+ documentLoader?: DocumentLoader;
+ contextLoader?: DocumentLoader;
+ suppressError?: boolean;
+}
+
+export async function* iterateCollection(
+ collection: Collection,
+ options?: IterateCollectionOptions,
+): AsyncIterable {
+ if (collection.firstId == null) {
+ for await (const item of collection.getItems(options)) {
+ if (item instanceof Link) continue;
+ yield item;
+ }
+ return;
+ }
+ let part = await collection.getFirst(options);
+ while (part != null && !(part instanceof Link)) {
+ for await (const item of part.getItems(options)) {
+ if (item instanceof Link) continue;
+ yield item;
+ }
+ part = await part.getNext(options);
+ }
+}
diff --git a/src/federation/index.ts b/src/federation/index.ts
index 2a2b446..5b880a3 100644
--- a/src/federation/index.ts
+++ b/src/federation/index.ts
@@ -1,6 +1,7 @@
import {
Accept,
Activity,
+ Add,
Announce,
Article,
Create,
@@ -12,6 +13,7 @@ import {
Note,
PropertyValue,
Reject,
+ Remove,
Undo,
Update,
createFederation,
@@ -37,10 +39,12 @@ import db from "../db";
import redis, { createRedis } from "../redis";
import {
type NewLike,
+ type NewPinnedPost,
accountOwners,
accounts,
follows,
likes,
+ pinnedPosts,
posts,
} from "../schema";
import { search } from "../search";
@@ -95,6 +99,7 @@ federation
following: ctx.getFollowingUri(handle),
outbox: ctx.getOutboxUri(handle),
liked: ctx.getLikedUri(handle),
+ featured: ctx.getFeaturedUri(handle),
inbox: ctx.getInboxUri(handle),
endpoints: new Endpoints({
sharedInbox: ctx.getInboxUri(),
@@ -307,6 +312,34 @@ federation
return result[0].cnt;
});
+federation.setFeaturedDispatcher("/@{handle}/pinned", async (ctx, handle) => {
+ const owner = await db.query.accountOwners.findFirst({
+ where: eq(accountOwners.handle, handle),
+ with: { account: true },
+ });
+ if (owner == null) return null;
+ const items = await db.query.pinnedPosts.findMany({
+ where: eq(pinnedPosts.accountId, owner.id),
+ orderBy: desc(pinnedPosts.index),
+ with: {
+ post: {
+ with: {
+ account: { with: { owner: true } },
+ replyTarget: true,
+ media: true,
+ mentions: { with: { account: true } },
+ },
+ },
+ },
+ });
+ return {
+ items: items
+ .map((p) => p.post)
+ .filter((p) => p.visibility === "public" || p.visibility === "unlisted")
+ .map((p) => toObject(p, ctx)),
+ };
+});
+
const inboxLogger = getLogger(["hollo", "inbox"]);
federation
@@ -543,6 +576,48 @@ federation
});
}
})
+ .on(Add, async (ctx, add) => {
+ if (add.targetId == null) return;
+ const accountList = await db.query.accounts.findMany({
+ where: eq(accounts.featuredUrl, add.targetId.href),
+ });
+ const object = await add.getObject();
+ if (object instanceof Note || object instanceof Article) {
+ await db.transaction(async (tx) => {
+ const post = await persistPost(tx, search, object, ctx);
+ if (post == null) return;
+ for (const account of accountList) {
+ await tx.insert(pinnedPosts).values({
+ postId: post.id,
+ accountId: account.id,
+ } satisfies NewPinnedPost);
+ }
+ });
+ }
+ })
+ .on(Remove, async (ctx, remove) => {
+ if (remove.targetId == null) return;
+ const accountList = await db.query.accounts.findMany({
+ where: eq(accounts.featuredUrl, remove.targetId.href),
+ });
+ const object = await remove.getObject();
+ if (object instanceof Note || object instanceof Article) {
+ await db.transaction(async (tx) => {
+ const post = await persistPost(tx, search, object, ctx);
+ if (post == null) return;
+ for (const account of accountList) {
+ await tx
+ .delete(pinnedPosts)
+ .where(
+ and(
+ eq(pinnedPosts.postId, post.id),
+ eq(pinnedPosts.accountId, account.id),
+ ),
+ );
+ }
+ });
+ }
+ })
.on(Undo, async (ctx, undo) => {
const object = await undo.getObject();
if (
diff --git a/src/profile.tsx b/src/profile.tsx
index d56ef5b..15fcfff 100644
--- a/src/profile.tsx
+++ b/src/profile.tsx
@@ -11,6 +11,7 @@ import {
type Medium,
type Post,
accountOwners,
+ pinnedPosts,
posts,
} from "./schema";
@@ -44,7 +45,37 @@ app.get("/", async (c) => {
replyTarget: { with: { account: true } },
},
});
- return c.html();
+ const pinnedPostList = await db.query.pinnedPosts.findMany({
+ where: and(eq(pinnedPosts.accountId, owner.id)),
+ orderBy: desc(pinnedPosts.index),
+ with: {
+ post: {
+ with: {
+ account: true,
+ media: true,
+ sharing: {
+ with: {
+ account: true,
+ media: true,
+ replyTarget: { with: { account: true } },
+ },
+ },
+ replyTarget: { with: { account: true } },
+ },
+ },
+ },
+ });
+ return c.html(
+ p.post)
+ .filter(
+ (p) => p.visibility === "public" || p.visibility === "unlisted",
+ )}
+ />,
+ );
});
export interface ProfilePageProps {
@@ -61,9 +92,25 @@ export interface ProfilePageProps {
| null;
replyTarget: (Post & { account: Account }) | null;
})[];
+ pinnedPosts: (Post & {
+ account: Account;
+ media: Medium[];
+ sharing:
+ | (Post & {
+ account: Account;
+ media: Medium[];
+ replyTarget: (Post & { account: Account }) | null;
+ })
+ | null;
+ replyTarget: (Post & { account: Account }) | null;
+ })[];
}
-export const ProfilePage: FC = ({ accountOwner, posts }) => {
+export const ProfilePage: FC = ({
+ accountOwner,
+ posts,
+ pinnedPosts,
+}) => {
return (
= ({ accountOwner, posts }) => {
imageUrl={accountOwner.account.avatarUrl}
>
+ {pinnedPosts.map((post) => (
+
+ ))}
{posts.map((post) => (
))}
diff --git a/src/schema.ts b/src/schema.ts
index 25fcaf6..6af5bb8 100644
--- a/src/schema.ts
+++ b/src/schema.ts
@@ -2,7 +2,9 @@ import { relations } from "drizzle-orm";
import {
type AnyPgColumn,
bigint,
+ bigserial,
boolean,
+ foreignKey,
integer,
json,
jsonb,
@@ -11,6 +13,7 @@ import {
primaryKey,
text,
timestamp,
+ unique,
uuid,
varchar,
} from "drizzle-orm/pg-core";
@@ -49,6 +52,7 @@ export const accounts = pgTable("accounts", {
inboxUrl: text("inbox_url").notNull(),
followersUrl: text("followers_url"),
sharedInboxUrl: text("shared_inbox_url"),
+ featuredUrl: text("featured_url"),
followingCount: bigint("following_count", { mode: "number" }).default(0),
followersCount: bigint("followers_count", { mode: "number" }).default(0),
postsCount: bigint("posts_count", { mode: "number" }).default(0),
@@ -71,6 +75,7 @@ export const accountRelations = relations(accounts, ({ one, many }) => ({
posts: many(posts),
mentions: many(mentions),
likes: many(likes),
+ pinnedPosts: many(pinnedPosts),
}));
export type Account = typeof accounts.$inferSelect;
@@ -251,39 +256,50 @@ export const postTypeEnum = pgEnum("post_type", ["Article", "Note"]);
export type PostType = (typeof postTypeEnum.enumValues)[number];
-export const posts = pgTable("posts", {
- id: uuid("id").primaryKey(),
- iri: text("iri").notNull().unique(),
- type: postTypeEnum("type").notNull(),
- accountId: uuid("actor_id")
- .notNull()
- .references(() => accounts.id, { onDelete: "cascade" }),
- applicationId: uuid("application_id").references(() => applications.id, {
- onDelete: "set null",
- }),
- replyTargetId: uuid("reply_target_id").references(
- (): AnyPgColumn => posts.id,
- { onDelete: "set null" },
- ),
- sharingId: uuid("sharing_id").references((): AnyPgColumn => posts.id, {
- onDelete: "cascade",
+export const posts = pgTable(
+ "posts",
+ {
+ id: uuid("id").primaryKey(),
+ iri: text("iri").notNull().unique(),
+ type: postTypeEnum("type").notNull(),
+ accountId: uuid("actor_id")
+ .notNull()
+ .references(() => accounts.id, { onDelete: "cascade" }),
+ applicationId: uuid("application_id").references(() => applications.id, {
+ onDelete: "set null",
+ }),
+ replyTargetId: uuid("reply_target_id").references(
+ (): AnyPgColumn => posts.id,
+ { onDelete: "set null" },
+ ),
+ sharingId: uuid("sharing_id").references((): AnyPgColumn => posts.id, {
+ onDelete: "cascade",
+ }),
+ visibility: postVisibilityEnum("visibility").notNull(),
+ summaryHtml: text("summary_html"),
+ summary: text("summary"),
+ contentHtml: text("content_html"),
+ content: text("content"),
+ language: text("language"),
+ tags: jsonb("tags").notNull().default({}).$type>(),
+ sensitive: boolean("sensitive").notNull().default(false),
+ url: text("url"),
+ previewCard: jsonb("preview_card").$type(),
+ repliesCount: bigint("replies_count", { mode: "number" }).default(0),
+ sharesCount: bigint("shares_count", { mode: "number" }).default(0),
+ likesCount: bigint("likes_count", { mode: "number" }).default(0),
+ published: timestamp("published", { withTimezone: true }),
+ updated: timestamp("updated", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (table) => ({
+ uniqueIdAccountId: unique("posts_id_actor_id_unique").on(
+ table.id,
+ table.accountId,
+ ),
}),
- visibility: postVisibilityEnum("visibility").notNull(),
- summaryHtml: text("summary_html"),
- summary: text("summary"),
- contentHtml: text("content_html"),
- content: text("content"),
- language: text("language"),
- tags: jsonb("tags").notNull().default({}).$type>(),
- sensitive: boolean("sensitive").notNull().default(false),
- url: text("url"),
- previewCard: jsonb("preview_card").$type(),
- repliesCount: bigint("replies_count", { mode: "number" }).default(0),
- sharesCount: bigint("shares_count", { mode: "number" }).default(0),
- likesCount: bigint("likes_count", { mode: "number" }).default(0),
- published: timestamp("published", { withTimezone: true }),
- updated: timestamp("updated", { withTimezone: true }).notNull().defaultNow(),
-});
+);
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
@@ -317,6 +333,10 @@ export const postRelations = relations(posts, ({ one, many }) => ({
media: many(media),
mentions: many(mentions),
bookmarks: many(bookmarks),
+ pin: one(pinnedPosts, {
+ fields: [posts.id, posts.accountId],
+ references: [pinnedPosts.postId, pinnedPosts.accountId],
+ }),
}));
export const media = pgTable("media", {
@@ -373,6 +393,41 @@ export const mentionRelations = relations(mentions, ({ one }) => ({
}),
}));
+export const pinnedPosts = pgTable(
+ "pinned_posts",
+ {
+ index: bigserial("index", { mode: "number" }).notNull().primaryKey(),
+ postId: uuid("post_id").notNull(),
+ accountId: uuid("account_id")
+ .notNull()
+ .references(() => accounts.id, { onDelete: "cascade" }),
+ created: timestamp("created", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (table) => ({
+ uniquePostIdAccountId: unique().on(table.postId, table.accountId),
+ postReference: foreignKey({
+ columns: [table.postId, table.accountId],
+ foreignColumns: [posts.id, posts.accountId],
+ }).onDelete("cascade"),
+ }),
+);
+
+export const pinnedPostRelations = relations(pinnedPosts, ({ one }) => ({
+ post: one(posts, {
+ fields: [pinnedPosts.postId, pinnedPosts.accountId],
+ references: [posts.id, posts.accountId],
+ }),
+ account: one(accounts, {
+ fields: [pinnedPosts.accountId],
+ references: [accounts.id],
+ }),
+}));
+
+export type PinnedPost = typeof pinnedPosts.$inferSelect;
+export type NewPinnedPost = typeof pinnedPosts.$inferInsert;
+
export const likes = pgTable(
"likes",
{