diff --git a/.env.example b/.env.example index 25a0c494..d97f55e4 100644 --- a/.env.example +++ b/.env.example @@ -3,17 +3,15 @@ DATABASE_URL="postgresql://postgres.USERNAME:PASSWORD@LOCATION.pooler.supabase.com:PORT/postgres" JWT_SECRET="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+AAAAAAAAAAAAAAAAA==" -# This section consists of simply splitting Section 1's DATABASE_URL -DB_HOST="LOCATION.pooler.supabase.com" -DB_USER="postgres.USERNAME" -DB_PASSWORD="PASSWORD" -DB_NAME="postgres" - # -------- -PUBLIC_SUPABASE_URL="https://AAAAAAAAAAAA.supabase.co" -PUBLIC_SUPABASE_ANON_KEY="AAAAAAAAAA.AAAAAAAAA.AAAAAAAAAAAAAAA" +PUBLIC_DISCORD_CLIENT_ID="" +DISCORD_CLIENT_SECRET="" # -------- +# Ratelimits +UPSTASH_REDIS_REST_URL="https://AAAAAA-AAAAA-38294.upstash.io" +UPSTASH_REDIS_REST_TOKEN="AAAAAAAAAAAAAAAAAA" + # MinIO MINIO_ENDPOINT="localhost" MINIO_ACCESS_KEY="AAAAAAAAAAAAAAAA" @@ -28,4 +26,4 @@ DISCORD_CHANNEL_ID="9999999999999999" # Channel to send reports to ADMIN_KEY="AAAAAAAAAAAAAAAAAAAAAA" # Key to communicate between the API and the bot. Required for endpoint /verify & /ban API_BASE_URL="http://localhost:5173" # The API URL (usually port 5173) SUDO_USER_ID="AAAAAAAAAAAAAAAAAAA" # The (valid) Lyntr user ID to use as an "agent" when fetching Lynt content for reporting. (since views must count regardless) -DISCORD_ADMIN_ROLE="9999999999999999" # Only users with this role can use /verify or ban users. \ No newline at end of file +DISCORD_ADMIN_ROLE="9999999999999999" # Only users with this role can use /verify or ban users. diff --git a/README.md b/README.md index 82670336..88d64749 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# Moved to [NeoLyntr](https://github.com/NeoLyntr/NeoLyntr) +

Lyntr.com - the micro-blogging social media with an IQ test

[Privacy Policy](https://lyntr.com/privacy) | [Terms of Service](https://lyntr.com/tos) | [License](https://github.com/face-hh/lyntr/blob/master/LICENSE.md) | [YouTube video](https://youtu.be/-D2L3gHqcUA) @@ -10,12 +12,17 @@ Light mode - Lyntr ![Lyntr.com - white](github-assets/banner_white.png) +# Issues +PLEASE before you make a issue read all the open and closed issues, use the search make sure a similar issue hasn't been posted by someone else before you make a new issue! + +If you have anything to add to a issue you can do so. Anymore information about a particular issue will be helpful! + # Self-host First, we need to setup the `.env` file with the right secrets. - Rename `.env.example` to `.env` -## Supabase -Firstly we have to setup the database. Head to https://supabase.com and **create an account.** +## Database +Firstly we have to setup the database we are going to use supabase here but you should be able to use any postgres database. Head to https://supabase.com and **create an account.** ![New project](github-assets/supabase1.png) @@ -33,15 +40,8 @@ Then click **Create new project**. It will take a few minutes, so in the meantim ![New project - 3](github-assets/supabase3.png) On this page we can see the **Project API keys** and **Project configuration** sections. -- Copy the `anon public` secret and put it in your `.env`: -```python -PUBLIC_SUPABASE_ANON_KEY="ey........................" -``` -- Copy the `Project Configuration` > `URL`: -```python -PUBLIC_SUPABASE_URL="https://.....supabase.co" -``` - Copy the `Project Configuration` > `JWT Secret`: +- You could also generate one with password sites or `node -e "console.log(require('crypto').randomBytes(32).toString(hex));"` ```python JWT_SECRET="..........x............x........." ``` @@ -56,13 +56,6 @@ DATABASE_URL="postgresql://postgres.USERNAME:PASSWORD/options" If you clicked `Copy` on the Connect page, you should already have the `USERNAME`. Simply replace `PASSWORD` with the one you put at: > For the **database password**, you could generate a random password or input yours. **Make sure to save it.** -Now simply split the `DATABASE_URL` components: -```python -DB_HOST="LOCATION.pooler.supabase.com" # Change "LOCATION" -DB_USER="postgres.USERNAME" # Change "USERNAME" -DB_PASSWORD="PASSWORD" # Change "PASSWORD" -DB_NAME="postgres" # Can be left like this -``` ![New project - 6](github-assets/supabase6.png) Now run the following: @@ -72,9 +65,20 @@ npx drizzle-kit migrate npx drizzle-kit push ``` -And follow [this guide on how to enable the Discord auth in Supabase](https://supabase.com/docs/guides/auth/social-login/auth-discord), until the code part. It should look something like this. -![image](https://github.com/user-attachments/assets/a8bd3043-3cae-4d46-b42c-0a576bcf59f5) +## Discord +Now that the database is set up you will need to create a application on the [Discord Developer Portal](https://discord.com/developers/docs/intro) + +You can then copy the client id and client secrets into the corresponding lines of your env: +```python +PUBLIC_DISCORD_CLIENT_ID="" +DISCORD_CLIENT_SECRET="" +``` + +## Ratelimits +For ratelimits we use [upstash redis](https://upstash.com/). +Create a redis and then copy these values into your env. +![upstash redis](github-assets/redis.png) ## MinIO We need Min.io for images. This and the next step can be omitted if you don't need *Image support* / *reporting*. diff --git a/drizzle.config.ts b/drizzle.config.ts index c375972a..11502239 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -3,19 +3,12 @@ import type { Config } from 'drizzle-kit'; config({ path: '.env' }) -if (!process.env.DB_HOST || !process.env.DB_USER || !process.env.DB_PASSWORD || !process.env.DB_NAME) { - throw new Error('Missing database credentials in environment variables'); -} - export default { schema: './src/lib/server/schema.ts', out: './drizzle', dialect: 'postgresql', // 'postgresql' | 'mysql' | 'sqlite' dbCredentials: { - host: process.env.DB_HOST, - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME, - ssl: false + url: process.env.DATABASE_URL, + ssl: "prefer" }, } satisfies Config; diff --git a/drizzle/0002_goofy_prism.sql b/drizzle/0002_goofy_prism.sql new file mode 100644 index 00000000..646c49e0 --- /dev/null +++ b/drizzle/0002_goofy_prism.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS "messages" ( + "id" bigserial PRIMARY KEY NOT NULL, + "sender_id" text, + "receiver_id" text, + "content" text NOT NULL, + "image" text, + "referenced_lynt_id" text, + "read" boolean DEFAULT false, + "created_at" timestamp DEFAULT now() +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_receiver_id_users_id_fk" FOREIGN KEY ("receiver_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_referenced_lynt_id_lynts_id_fk" FOREIGN KEY ("referenced_lynt_id") REFERENCES "public"."lynts"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "created_at_idx" ON "messages" USING btree ("created_at"); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..67df4133 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,617 @@ +{ + "id": "e7adf0f7-93ff-45de-874b-0599733b1908", + "prevId": "b2855ae0-cf08-4251-ad0f-ea64d0cf1b7f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.followers": { + "name": "followers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "followers_user_id_users_id_fk": { + "name": "followers_user_id_users_id_fk", + "tableFrom": "followers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "followers_follower_id_users_id_fk": { + "name": "followers_follower_id_users_id_fk", + "tableFrom": "followers", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "followers_pkey": { + "name": "followers_pkey", + "columns": [ + "user_id", + "follower_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.history": { + "name": "history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lynt_id": { + "name": "lynt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_user_lynt": { + "name": "unique_user_lynt", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lynt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "history_user_id_users_id_fk": { + "name": "history_user_id_users_id_fk", + "tableFrom": "history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "history_lynt_id_lynts_id_fk": { + "name": "history_lynt_id_lynts_id_fk", + "tableFrom": "history", + "tableTo": "lynts", + "columnsFrom": [ + "lynt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.likes": { + "name": "likes", + "schema": "", + "columns": { + "lynt_id": { + "name": "lynt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "liked_at": { + "name": "liked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "likes_lynt_id_lynts_id_fk": { + "name": "likes_lynt_id_lynts_id_fk", + "tableFrom": "likes", + "tableTo": "lynts", + "columnsFrom": [ + "lynt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "likes_user_id_users_id_fk": { + "name": "likes_user_id_users_id_fk", + "tableFrom": "likes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "likes_pkey": { + "name": "likes_pkey", + "columns": [ + "lynt_id", + "user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.lynts": { + "name": "lynts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "shares": { + "name": "shares", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "has_link": { + "name": "has_link", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "has_image": { + "name": "has_image", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "reposted": { + "name": "reposted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "parent": { + "name": "parent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "lynts_user_id_users_id_fk": { + "name": "lynts_user_id_users_id_fk", + "tableFrom": "lynts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lynts_parent_lynts_id_fk": { + "name": "lynts_parent_lynts_id_fk", + "tableFrom": "lynts", + "tableTo": "lynts", + "columnsFrom": [ + "parent" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receiver_id": { + "name": "receiver_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referenced_lynt_id": { + "name": "referenced_lynt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "created_at_idx": { + "name": "created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_receiver_id_users_id_fk": { + "name": "messages_receiver_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "receiver_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_referenced_lynt_id_lynts_id_fk": { + "name": "messages_referenced_lynt_id_lynts_id_fk", + "tableFrom": "messages", + "tableTo": "lynts", + "columnsFrom": [ + "referenced_lynt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lynt_id": { + "name": "lynt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notifications_source_user_id_users_id_fk": { + "name": "notifications_source_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "source_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notifications_lynt_id_lynts_id_fk": { + "name": "notifications_lynt_id_lynts_id_fk", + "tableFrom": "notifications", + "tableTo": "lynts", + "columnsFrom": [ + "lynt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "default": "'Nothing here yet...'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "iq": { + "name": "iq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'a'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_handle_unique": { + "name": "users_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0ae134a1..8d8dcdb0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1722220455698, "tag": "0001_overrated_deadpool", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1722730351277, + "tag": "0002_goofy_prism", + "breakpoints": true } ] } \ No newline at end of file diff --git a/github-assets/redis.png b/github-assets/redis.png new file mode 100755 index 00000000..f3abf436 Binary files /dev/null and b/github-assets/redis.png differ diff --git a/package-lock.json b/package-lock.json index 426c3fd1..da8e6887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,9 @@ "@types/ws": "^8.5.11", "@upstash/ratelimit": "^2.0.1", "@upstash/redis": "^1.34.0", - "bits-ui": "^0.21.12", + "bits-ui": "^0.21.13", "clsx": "^2.1.1", + "dayjs": "^1.11.12", "discord.js": "^14.15.3", "dompurify": "^3.1.6", "dotenv": "^16.4.5", @@ -69,6 +70,7 @@ "svelte": "^4.2.7", "svelte-check": "^3.6.0", "svelte-magnifier": "^0.0.1", + "svelte-virtual-scroll-list": "^1.3.0", "sveltekit-sse": "^0.13.2", "tailwindcss": "^3.4.4", "ts-node": "^10.9.2", @@ -3116,9 +3118,9 @@ } }, "node_modules/bits-ui": { - "version": "0.21.12", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.12.tgz", - "integrity": "sha512-Cf0iB+ZKwA0ZjkpixrhrZK9PC6pGPFleW/65Xc/z0lpGvWaFtdOhiYEntCHHxZ0VihP3aJaG0OBhUBIbmAePaA==", + "version": "0.21.13", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.13.tgz", + "integrity": "sha512-7nmOh6Ig7ND4DXZHv1FhNsY9yUGrad0+mf3tc4YN//3MgnJT1LnHtk4HZAKgmxCOe7txSX7/39LtYHbkrXokAQ==", "dependencies": { "@internationalized/date": "^3.5.1", "@melt-ui/svelte": "0.76.2", @@ -3683,10 +3685,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.11", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", - "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==", - "optional": true + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz", + "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==" }, "node_modules/debug": { "version": "4.3.5", @@ -4725,9 +4726,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz", - "integrity": "sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", "funding": [ { "type": "github", @@ -7630,6 +7631,15 @@ "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" } }, + "node_modules/svelte-virtual-scroll-list": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/svelte-virtual-scroll-list/-/svelte-virtual-scroll-list-1.3.0.tgz", + "integrity": "sha512-rkU993mMsTboFRlygExhYeLJwysaFxyzfTsAfOtDklGIyd0wB31eZtYSAHAcz/WaZCEwjn+GKXfx5jM1xUv3GQ==", + "dev": true, + "peerDependencies": { + "svelte": ">=3.5.0" + } + }, "node_modules/sveltekit-sse": { "version": "0.13.2", "resolved": "https://registry.npmjs.org/sveltekit-sse/-/sveltekit-sse-0.13.2.tgz", diff --git a/package.json b/package.json index 548c8937..f66cebc0 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "svelte": "^4.2.7", "svelte-check": "^3.6.0", "svelte-magnifier": "^0.0.1", + "svelte-virtual-scroll-list": "^1.3.0", "sveltekit-sse": "^0.13.2", "tailwindcss": "^3.4.4", "ts-node": "^10.9.2", @@ -54,8 +55,9 @@ "@types/ws": "^8.5.11", "@upstash/ratelimit": "^2.0.1", "@upstash/redis": "^1.34.0", - "bits-ui": "^0.21.12", + "bits-ui": "^0.21.13", "clsx": "^2.1.1", + "dayjs": "^1.11.12", "discord.js": "^14.15.3", "dompurify": "^3.1.6", "dotenv": "^16.4.5", diff --git a/src/app.html b/src/app.html index 7bad6484..d22cf034 100644 --- a/src/app.html +++ b/src/app.html @@ -13,9 +13,7 @@ - - - + %sveltekit.head% @@ -23,4 +21,3 @@
%sveltekit.body%
- \ No newline at end of file diff --git a/src/lib/components/ui/sheet/index.ts b/src/lib/components/ui/sheet/index.ts new file mode 100644 index 00000000..7e1f764b --- /dev/null +++ b/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,106 @@ +import { Dialog as SheetPrimitive } from "bits-ui"; +import { type VariantProps, tv } from "tailwind-variants"; + +import Portal from "./sheet-portal.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Content from "./sheet-content.svelte"; +import Header from "./sheet-header.svelte"; +import Footer from "./sheet-footer.svelte"; +import Title from "./sheet-title.svelte"; +import Description from "./sheet-description.svelte"; + +const Root = SheetPrimitive.Root; +const Close = SheetPrimitive.Close; +const Trigger = SheetPrimitive.Trigger; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; + +export const sheetVariants = tv({ + base: "bg-background fixed z-50 gap-4 p-6 shadow-lg", + variants: { + side: { + top: "inset-x-0 top-0 border-b", + bottom: "inset-x-0 bottom-0 border-t", + left: "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", + right: "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, +}); + +export const sheetTransitions = { + top: { + in: { + y: "-100%", + duration: 500, + opacity: 1, + }, + out: { + y: "-100%", + duration: 300, + opacity: 1, + }, + }, + bottom: { + in: { + y: "100%", + duration: 500, + opacity: 1, + }, + out: { + y: "100%", + duration: 300, + opacity: 1, + }, + }, + left: { + in: { + x: "-100%", + duration: 500, + opacity: 1, + }, + out: { + x: "-100%", + duration: 300, + opacity: 1, + }, + }, + right: { + in: { + x: "100%", + duration: 500, + opacity: 1, + }, + out: { + x: "100%", + duration: 300, + opacity: 1, + }, + }, +}; + +export type Side = VariantProps["side"]; diff --git a/src/lib/components/ui/sheet/sheet-content.svelte b/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 00000000..421b8bd6 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,47 @@ + + + + + + + + + Close + + + diff --git a/src/lib/components/ui/sheet/sheet-description.svelte b/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 00000000..0de063e9 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/lib/components/ui/sheet/sheet-footer.svelte b/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 00000000..a235d1f8 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,16 @@ + + +
+ +
diff --git a/src/lib/components/ui/sheet/sheet-header.svelte b/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 00000000..2650ef9f --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,13 @@ + + +
+ +
diff --git a/src/lib/components/ui/sheet/sheet-overlay.svelte b/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 00000000..f4746265 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/ui/sheet/sheet-portal.svelte b/src/lib/components/ui/sheet/sheet-portal.svelte new file mode 100644 index 00000000..5543a3b3 --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-portal.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/lib/components/ui/sheet/sheet-title.svelte b/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 00000000..fff1b70c --- /dev/null +++ b/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/lib/inview.ts b/src/lib/inview.ts new file mode 100644 index 00000000..a3dfb1c1 --- /dev/null +++ b/src/lib/inview.ts @@ -0,0 +1,139 @@ +// https://github.com/maciekgrzybek/svelte-inview +import { tick } from 'svelte'; + +export type Prefix = 'inview'; +export type EventBase = 'change' | 'leave' | 'enter' | 'init'; +export type Event = `${Prefix}_${EventBase}`; + +export type Options = { + root?: HTMLElement | null; + rootMargin?: string; + threshold?: number | number[]; + unobserveOnEnter?: boolean; +}; + +export type Position = { + x?: number; + y?: number; +}; + +// Types below needs to be manually copied to additional-svelte.jsx.d.ts file - more details there +type Direction = 'up' | 'down' | 'left' | 'right'; + +export type ScrollDirection = { + vertical?: Direction; + horizontal?: Direction; +}; + +export type ObserverEventDetails = { + inView: boolean; + entry: IntersectionObserverEntry; + scrollDirection: ScrollDirection; + node: HTMLElement; + observer: IntersectionObserver; +}; + +export type LifecycleEventDetails = { + node: HTMLElement; + observer: IntersectionObserver; +}; +const defaultOptions: Options = { + root: null, + rootMargin: '0px', + threshold: 0, + unobserveOnEnter: false, +}; + +const createEvent = ( + name: Event, + detail: T +): CustomEvent => new CustomEvent(name, { detail }); + +export function inview(node: HTMLElement, options: Options = {}) { + const { root, rootMargin, threshold, unobserveOnEnter }: Options = { + ...defaultOptions, + ...options, + }; + + let prevPos: Position = { + x: undefined, + y: undefined, + }; + + let scrollDirection: ScrollDirection = { + vertical: undefined, + horizontal: undefined, + }; + + if (typeof IntersectionObserver !== 'undefined' && node) { + const observer = new IntersectionObserver( + (entries, _observer) => { + entries.forEach((singleEntry) => { + if (prevPos.y > singleEntry.boundingClientRect.y) { + scrollDirection.vertical = 'up'; + } else { + scrollDirection.vertical = 'down'; + } + + if (prevPos.x > singleEntry.boundingClientRect.x) { + scrollDirection.horizontal = 'left'; + } else { + scrollDirection.horizontal = 'right'; + } + + prevPos = { + y: singleEntry.boundingClientRect.y, + x: singleEntry.boundingClientRect.x, + }; + + const detail: ObserverEventDetails = { + inView: singleEntry.isIntersecting, + entry: singleEntry, + scrollDirection, + node, + observer: _observer, + }; + + node.dispatchEvent(createEvent('inview_change', detail)); + //@ts-expect-error only for backward compatibility + node.dispatchEvent(createEvent('change', detail)); + + if (singleEntry.isIntersecting) { + node.dispatchEvent(createEvent('inview_enter', detail)); + //@ts-expect-error only for backward compatibility + node.dispatchEvent(createEvent('enter', detail)); + + unobserveOnEnter && _observer.unobserve(node); + } else { + node.dispatchEvent(createEvent('inview_leave', detail)); + //@ts-expect-error only for backward compatibility + node.dispatchEvent(createEvent('leave', detail)); + } + }); + }, + { + root, + rootMargin, + threshold, + } + ); + + tick().then(() => { + node.dispatchEvent( + createEvent('inview_init', { observer, node }) + ); + node.dispatchEvent( + //@ts-expect-error only for backward compatibility + createEvent('init', { observer, node }) + ); + }); + + observer.observe(node); + + return { + destroy() { + observer.unobserve(node); + }, + }; + } +} diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index cbcd647f..936f7857 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -6,6 +6,7 @@ import { lynts, users } from './server/schema'; import { eq } from 'drizzle-orm'; import { config } from 'dotenv'; import { deleteLynt } from '../routes/api/util'; +import sharp from 'sharp'; config({ path: '.env' }); @@ -24,8 +25,14 @@ tf.enableProdMode(); const model = await nsfw.load(); export async function isImageNsfw(image: Buffer) { - const tfImage = (await tf.node.decodeImage(image, 3)) as Tensor3D; + const { data, info } = await sharp(image) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const tfImage = tf.tensor3d(new Int32Array(data), [info.height, info.width, 3], "int32"); const predictions = await model.classify(tfImage); + tfImage.dispose(); + for (const prediction of predictions) { if ( prediction.probability > PREDICTION_TRESHOLD && diff --git a/src/lib/server/schema.ts b/src/lib/server/schema.ts index 8a299c19..f1ab83c9 100644 --- a/src/lib/server/schema.ts +++ b/src/lib/server/schema.ts @@ -1,4 +1,4 @@ -import { boolean, date, pgTable, serial, timestamp, varchar, integer, type AnyPgColumn, primaryKey, text, uuid, uniqueIndex } from 'drizzle-orm/pg-core'; +import { boolean, date, pgTable, bigserial, timestamp, varchar, integer, type AnyPgColumn, primaryKey, text, uuid, uniqueIndex, index } from 'drizzle-orm/pg-core'; import { drizzle } from 'drizzle-orm/node-postgres'; export const users = pgTable('users', { @@ -27,6 +27,21 @@ export const lynts = pgTable('lynts', { parent: text('parent').references((): AnyPgColumn => lynts.id) }); +export const messages = pgTable('messages', { + id: bigserial('id', { mode: 'number' }).primaryKey(), + sender_id: text('sender_id').references(() => users.id), + receiver_id: text('receiver_id').references(() => users.id), + content: text('content').notNull(), + image: text('image'), + referencedLyntId: text('referenced_lynt_id').references(() => lynts.id), + read: boolean('read').default(false), + created_at: timestamp('created_at').defaultNow() +}, (table) => { + return { + created_atIdx: index("created_at_idx").on(table.created_at), + }; +}); + export const followers = pgTable('followers', { user_id: text('user_id').references(() => users.id).notNull(), follower_id: text('follower_id').references(() => users.id).notNull(), @@ -65,4 +80,4 @@ export const history = pgTable('history', { return { uniqueUserLynt: uniqueIndex('unique_user_lynt').on(table.user_id, table.lynt_id), } -}); \ No newline at end of file +}); diff --git a/src/lib/sse.ts b/src/lib/sse.ts index 8b113f0f..f3f9c444 100644 --- a/src/lib/sse.ts +++ b/src/lib/sse.ts @@ -1,25 +1,47 @@ -let connections: Set = new Set(); +let connections: Map> = new Map(); -export function addConnection(controller: ReadableStreamDefaultController) { - connections.add(controller); +export function addConnection(userId: string, controller: ReadableStreamDefaultController) { + if (!connections.has(userId)) { + connections.set(userId, new Set()); + } + connections.get(userId).add(controller); } -export function removeConnection(controller: ReadableStreamDefaultController) { - connections.delete(controller); +export function removeConnection(userId: string, controller: ReadableStreamDefaultController) { + if (connections.has(userId)) { + connections.get(userId).delete(controller); + if (connections.get(userId).size === 0) { + connections.delete(userId); + } + } } -export function sendMessage(message: any) { - connections.forEach(controller => { - try { - controller.enqueue(`data: ${JSON.stringify(message)}\n\n`); - } catch (error) { - if (error instanceof TypeError && error.message.includes('Controller is already closed')) { - // If the controller is closed, remove it from the set - removeConnection(controller); - } else { - // If it's a different error, log it - console.error('Error sending message:', error); - } +function sendToController(controller: ReadableStreamDefaultController, message: string, userId: string) { + try { + controller.enqueue(`data: ${JSON.stringify(message)}\n\n`); + } catch (error) { + if (error instanceof TypeError && error.message.includes('Controller is already closed')) { + // If the controller is closed, remove it from the set + removeConnection(userId, controller); + } else { + // If it's a different error, log it + console.error('Error sending message:', error); } - }); -} \ No newline at end of file + } +} + +export function sendMessage(message: string, userId: string | null = null) { + if (userId) { + if (connections.has(userId)) { + connections.get(userId).forEach((controller) => { + sendToController(controller, message, userId); + }); + } + } else { + connections.forEach((controllers, key) => { + controllers.forEach((controller) => { + sendToController(controller, message, key); + }); + }); + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 8aeb68e3..ed82aa99 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -7,6 +7,8 @@ import Auth from './Auth.svelte'; import AccountCreator from './AccountCreator.svelte'; import MainPage from './MainPage.svelte'; + import { cdnUrl } from './stores'; + import Cookies from 'js-cookie'; import type { PageData } from './$types'; diff --git a/src/routes/AccountCreator.svelte b/src/routes/AccountCreator.svelte index f3179605..ee2f7d04 100644 --- a/src/routes/AccountCreator.svelte +++ b/src/routes/AccountCreator.svelte @@ -73,7 +73,7 @@
Lyntr diff --git a/src/routes/Auth.svelte b/src/routes/Auth.svelte index bd8f048c..a4d345e1 100644 --- a/src/routes/Auth.svelte +++ b/src/routes/Auth.svelte @@ -28,7 +28,7 @@
- Lyntr + Lyntr
diff --git a/src/routes/DivInput.svelte b/src/routes/DivInput.svelte index 5ea04f01..e972ff4b 100644 --- a/src/routes/DivInput.svelte +++ b/src/routes/DivInput.svelte @@ -1,36 +1,70 @@ -
+
+
-
- {characterCount}/280
-
\ No newline at end of file + {#if lynt.length > charactersBeforeCount} +
+ {characterCount}/{maxLength} +
+ {/if} +
diff --git a/src/routes/LoadingSpinner.svelte b/src/routes/LoadingSpinner.svelte index 07796ba0..6a9a56ca 100644 --- a/src/routes/LoadingSpinner.svelte +++ b/src/routes/LoadingSpinner.svelte @@ -1,5 +1,5 @@ diff --git a/src/routes/Lynt.svelte b/src/routes/Lynt.svelte index 17393e35..f947f57e 100644 --- a/src/routes/Lynt.svelte +++ b/src/routes/Lynt.svelte @@ -1,11 +1,17 @@ + + + + +
+ + {shareData.title} + + {#if !friendListLoaded} + + {:else if friendsList.length > 0} + { + // load next page of friends + if (loadingNextFriends) return; + friendListPage += 1; + await appendFriends(); + }} + > + + + {:else} + No Friends + {/if} + +
+ +
+ + +
+
+
+
+
openLynt(id)} class="mb-2 w-full text-left">
-
+
{#if reposted && parentId}
openLynt(parentId)}> -
+ +
+ + + {#if parentUserHandle} {/if}
{/if} -
+
diff --git a/src/routes/LyntContents.svelte b/src/routes/LyntContents.svelte index 758d8014..a0983bca 100644 --- a/src/routes/LyntContents.svelte +++ b/src/routes/LyntContents.svelte @@ -11,6 +11,12 @@ import { Copy, Ellipsis, Trash } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import Report from './Report.svelte'; + import { createEventDispatcher } from 'svelte'; + + + import DOMPurify from 'dompurify'; + import { page } from '$app/stores'; + import { Button } from '@/components/ui/button'; function getTimeElapsed(date: Date | string) { if (typeof date === 'string') date = new Date(date); @@ -56,6 +62,11 @@ } let popoverOpened = false; + const dispatcher = createEventDispatcher<{ + delete: { + id: string + } + }>(); export let truncateContent: boolean = false; export let username; @@ -73,6 +84,14 @@ export let has_image: boolean | null; export let postId: string; + export let reposted = false; + + + let contentElement: HTMLSpanElement | null = null; + content = content!; + let clickingExternalLink = false; + let externalLink: URL | null = null; + const formattedDate = formatDateTooltip(createdAt); async function handleDelete() { @@ -80,6 +99,8 @@ if (response.status === 200) { toast(`Your post has been permanently deleted.`); + dispatcher('delete', { id: postId }); + popoverOpened = false; } else if (response.status === 403) { toast(`Missing access - frontend may be desynchronised.`); } else { @@ -118,14 +139,14 @@ {/if}
-
+
{username} @@ -155,7 +176,7 @@
This user is verified.
@@ -169,10 +190,11 @@ class="py-0.25 flex select-none items-center rounded-xl border border-transparent bg-primary px-1.5 text-base font-semibold text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" >{iq} + {#if !smaller}
+ {/if} - @@ -207,6 +230,7 @@
+ {#if !reposted}
@@ -237,9 +261,10 @@
+ {/if}
- {truncated} + {truncated} {#if needsReadMore} Click to Read more... @@ -247,5 +272,5 @@
{#if has_image} - ok + ok {/if} diff --git a/src/routes/MainPage.svelte b/src/routes/MainPage.svelte index 6e7420ed..1b9aba73 100644 --- a/src/routes/MainPage.svelte +++ b/src/routes/MainPage.svelte @@ -2,7 +2,7 @@ import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; import { Separator } from '$lib/components/ui/separator'; - import { Moon, Reply, Sun, X } from 'lucide-svelte'; + import { ImageUp, Moon, Reply, Sun, X } from 'lucide-svelte'; import { cdnUrl, v } from './stores'; import { source } from 'sveltekit-sse'; @@ -19,8 +19,10 @@ import Search from './Search.svelte'; import Notifications from './Notifications.svelte'; import ProfilePage from './ProfilePage.svelte'; + import MessagesPage from './MessagesPage.svelte'; import { goto } from '$app/navigation'; import TopTab from './TopTab.svelte'; + import DivInput from './DivInput.svelte'; import type { FeedItem } from './stores'; export let username: string; @@ -28,10 +30,12 @@ // export let created_at: string; // export let iq: number; export let id: string; + export let otherId: string | null = null; export let lyntOpened: string | null = null; export let profileOpened: string | null = null; let comment: string; + let postCommentDisabled: boolean = true; let loadingFeed = true; let page: string = 'home'; @@ -48,15 +52,22 @@ let currentTab = 'For you'; const tabs = ['For you', 'Following', 'New']; + let eventSource: EventSource | undefined = undefined; + function handleTabChange(tab: string) { currentTab = tab; fetchFeed(); + if (eventSource) { + eventSource.close(); + } if (currentTab === tabs[2]) { - const eventSource = new EventSource('/api/sse'); + eventSource = new EventSource('/api/sse'); eventSource.onmessage = async (event) => { - // Render the entire lynt data to not fetch the lynt each time - await renderLyntAtTop(JSON.parse(event.data)); + const data = JSON.parse(event.data); + if (data.type === 'lynt_add') { + await renderLyntAtTop(data.data); + } }; } } @@ -87,10 +98,12 @@ })(); } else if (profileOpened !== null) { page = `profile${profileOpened}`; + } else if (otherId !== null) { + page = `messages/${otherId}`; } async function getLynt(lyntOpened: string) { - const response = await fetch('api/lynt?id=' + lyntOpened, { method: 'GET' }); + const response = await fetch('/api/lynt?id=' + lyntOpened, { method: 'GET' }); if (response.status !== 200) toast('Error loading lynt!'); @@ -103,11 +116,12 @@ async function fetchFeed(append = false) { const response = await fetch( - `api/feed?type=${currentTab}&excludePosts=${feed.map((post: any) => post.id).join(',')}`, + `/api/feed?type=${currentTab}&excludePosts=${feed.map((post: any) => post.id).join(',')}`, { method: 'GET' } ); if (response.status !== 200) { + console.log(currentTab); toast('Error generating feed! Please refresh the page'); return; } @@ -149,7 +163,7 @@ } async function getComments(id: string) { - const response = await fetch('api/comments?id=' + id, { + const response = await fetch('/api/comments?id=' + id, { method: 'GET' }); @@ -161,12 +175,48 @@ return res.map((post: any) => ({ ...post })); } + let image: File | null = null; + let imagePreview: string | null = null; + let fileInput: HTMLInputElement; + + const onFileSelected = (e: Event) => { + const target = e.target as HTMLInputElement; + if (target.files && target.files[0]) { + image = target.files[0]; + let reader = new FileReader(); + reader.readAsDataURL(image); + reader.onload = (e) => { + imagePreview = e.target?.result as string; + }; + + postCommentDisabled = false; + } + }; + async function postComment() { - const response = await fetch('api/comment', { - method: 'POST', - body: JSON.stringify({ id: selectedLynt?.id, content: comment }) + + if (comment.trim() == '' && image == null) { + toast("Cannot post an empty comment."); + return; + } + + const formData = new FormData(); + formData.append('id', selectedLynt?.id ?? ''); + formData.append('content', comment); + + if (image) { + formData.append('image', image, image.name); + } + + const response = await fetch('/api/comment', { + method: "POST", + body: formData }); + + image = null; + imagePreview = null; comment = ''; + postCommentDisabled = true; if (response.status !== 201) { if (response.status == 429) @@ -200,11 +250,28 @@ const lynt = await getLynt(lyntId); feed = [lynt].concat(feed); } - function handlePaste(event: ClipboardEvent) { - event.preventDefault(); - const text = event.clipboardData?.getData('text/plain') || ''; - document.execCommand('insertText', false, text); + + function goHome() + { + currentPage.set('home'); + goto('/'); } + + + function handleInput(event: Event) { + if (comment.trim() == '' && image == null) { + postCommentDisabled = true; + } else { + postCommentDisabled = false; + } + } + + function getStats(){ + if(!selectedLynt) return "💬 0 🔁 0 ❤️ 0 👁️ 0" + + return `💬 ${selectedLynt.commentCount.toLocaleString()} 🔁 ${selectedLynt.repostCount.toLocaleString()} ❤️ ${selectedLynt.likeCount.toLocaleString()} 👁️ ${selectedLynt.views.toLocaleString()}` + } +
@@ -216,8 +283,8 @@
-