├── src ├── controllers │ └── message.controller.ts ├── hooks.client.ts ├── lib │ ├── index.ts │ ├── components │ │ └── ui │ │ │ ├── checkbox │ │ │ ├── index.ts │ │ │ └── checkbox.svelte │ │ │ ├── input │ │ │ ├── index.ts │ │ │ └── input.svelte │ │ │ ├── textarea │ │ │ ├── index.ts │ │ │ └── textarea.svelte │ │ │ ├── badge │ │ │ ├── index.ts │ │ │ └── badge.svelte │ │ │ ├── toggle-group │ │ │ ├── index.ts │ │ │ ├── toggle-group-item.svelte │ │ │ └── toggle-group.svelte │ │ │ ├── toggle │ │ │ ├── index.ts │ │ │ └── toggle.svelte │ │ │ ├── dialog │ │ │ ├── dialog-close.svelte │ │ │ ├── dialog-trigger.svelte │ │ │ ├── dialog-title.svelte │ │ │ ├── dialog-description.svelte │ │ │ ├── dialog-header.svelte │ │ │ ├── dialog-footer.svelte │ │ │ ├── dialog-overlay.svelte │ │ │ ├── index.ts │ │ │ └── dialog-content.svelte │ │ │ ├── button │ │ │ ├── index.ts │ │ │ └── button.svelte │ │ │ └── popover │ │ │ ├── popover-trigger.svelte │ │ │ ├── index.ts │ │ │ └── popover-content.svelte │ ├── types.ts │ ├── _components │ │ └── MessageFeedback.svelte │ ├── utils │ │ ├── colors.ts │ │ ├── hashing.ts │ │ ├── pgp.ts │ │ ├── utils.ts │ │ └── localStorage.ts │ ├── profanityFilter.ts │ ├── utils.ts │ └── db.ts ├── global.d.ts ├── index.test.ts ├── hooks.server.ts ├── app.d.ts ├── models │ ├── file.schema.ts │ ├── room.schema.ts │ ├── messages.schema.ts │ └── listener.schema.ts ├── routes │ ├── api │ │ ├── images │ │ │ └── +server.ts │ │ ├── stats │ │ │ └── +server.ts │ │ ├── profanity │ │ │ └── +server.ts │ │ ├── title │ │ │ └── +server.ts │ │ ├── webhook │ │ │ └── +server.ts │ │ └── pgp │ │ │ └── +server.ts │ ├── li │ │ └── [room] │ │ │ ├── +page.server.ts │ │ │ ├── ListenerHeaderConfig.svelte │ │ │ ├── Modals │ │ │ ├── SettingsModal │ │ │ │ ├── PollingDurationSelector.svelte │ │ │ │ ├── ProfanityToggle.svelte │ │ │ │ └── WebhookSettings.svelte │ │ │ └── SettingsModal.svelte │ │ │ ├── Message │ │ │ ├── ImageThumbnail.svelte │ │ │ ├── BlurhashThumbnail.svelte │ │ │ └── Message.svelte │ │ │ ├── ListenerHeader.svelte │ │ │ ├── Header │ │ │ ├── CopyLink.svelte │ │ │ └── Mute.svelte │ │ │ ├── ListenerHeaderTitle.svelte │ │ │ └── +page.svelte │ ├── i │ │ ├── IdentityPill.svelte │ │ ├── +page.svelte │ │ └── IdentityList.svelte │ ├── MessagesButton.svelte │ ├── Stats.svelte │ ├── PGPPowerUser.svelte │ ├── +page.svelte │ ├── +layout.svelte │ └── b │ │ └── [room] │ │ └── +page.svelte ├── app.html ├── global copy.css └── global.css ├── .env.example ├── .husky ├── pre-commit └── commit-msg ├── .npmrc ├── .vscode └── settings.json ├── static ├── SMA.png ├── pre.png ├── favicon.ico ├── favicon.png ├── notify.wav ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png └── site.webmanifest ├── postcss.config.js ├── .eslintignore ├── .prettierignore ├── tests └── test.ts ├── .gitignore ├── playwright.config.ts ├── .prettierrc ├── components.json ├── vite.config.ts ├── tsconfig.json ├── .eslintrc.cjs ├── svelte.config.js ├── package.json ├── README.md └── LICENCE /src/controllers/message.controller.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Private 2 | SECRET_MONGO_URI= -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | # pnpm format 2 | exit 0 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "vscord.enabled": false 3 | } 4 | -------------------------------------------------------------------------------- /src/hooks.client.ts: -------------------------------------------------------------------------------- 1 | // Error handling can be added here if needed 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | -------------------------------------------------------------------------------- /static/SMA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobiMez/sma/HEAD/static/SMA.png -------------------------------------------------------------------------------- /static/pre.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobiMez/sma/HEAD/static/pre.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobiMez/sma/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobiMez/sma/HEAD/static/favicon.png -------------------------------------------------------------------------------- /static/notify.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobiMez/sma/HEAD/static/notify.wav -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobiMez/sma/HEAD/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobiMez/sma/HEAD/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobiMez/sma/HEAD/static/apple-touch-icon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md' { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobiMez/sma/HEAD/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobiMez/sma/HEAD/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/lib/components/ui/checkbox/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./checkbox.svelte"; 2 | export { 3 | Root, 4 | // 5 | Root as Checkbox, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/components/ui/input/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./input.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Input, 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/textarea/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./textarea.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Textarea, 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/components/ui/badge/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Badge } from "./badge.svelte"; 2 | export { badgeVariants, type BadgeVariant } from "./badge.svelte"; 3 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | interface IKeyPairs { 2 | prKey: string; 3 | pbKey: string; 4 | RC: string; 5 | uniqueString: string; 6 | } 7 | 8 | export type { IKeyPairs }; 9 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('sum test', () => { 4 | it('adds 1 + 2 to equal 3', () => { 5 | expect(1 + 2).toBe(3); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { dbConnect } from '$lib/db'; 2 | import type { Handle } from '@sveltejs/kit'; 3 | 4 | dbConnect(); 5 | 6 | export const handle: Handle = async ({ event, resolve }) => { 7 | return await resolve(event); 8 | }; 9 | -------------------------------------------------------------------------------- /src/lib/components/ui/toggle-group/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./toggle-group.svelte"; 2 | import Item from "./toggle-group-item.svelte"; 3 | 4 | export { 5 | Root, 6 | Item, 7 | // 8 | Root as ToggleGroup, 9 | Item as ToggleGroupItem, 10 | }; 11 | -------------------------------------------------------------------------------- /tests/test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | test('index page has expected h1', async ({ page }) => { 4 | await page.goto('/'); 5 | await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible(); 6 | }); 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | .history 12 | 13 | #jetbrains 14 | /.idea 15 | 16 | # Sentry Config File 17 | .sentryclirc 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/toggle/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./toggle.svelte"; 2 | export { 3 | toggleVariants, 4 | type ToggleSize, 5 | type ToggleVariant, 6 | type ToggleVariants, 7 | } from "./toggle.svelte"; 8 | 9 | export { 10 | Root, 11 | // 12 | Root as Toggle, 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-close.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-trigger.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | webServer: { 5 | command: 'npm run build && npm run preview', 6 | port: 4173 7 | }, 8 | testDir: 'tests', 9 | testMatch: /(.+\.)?(test|spec)\.[jt]s/ 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/_components/MessageFeedback.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | {message} 11 | -------------------------------------------------------------------------------- /src/lib/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | import Root, { 2 | type ButtonProps, 3 | type ButtonSize, 4 | type ButtonVariant, 5 | buttonVariants, 6 | } from "./button.svelte"; 7 | 8 | export { 9 | Root, 10 | type ButtonProps as Props, 11 | // 12 | Root as Button, 13 | buttonVariants, 14 | type ButtonProps, 15 | type ButtonSize, 16 | type ButtonVariant, 17 | }; 18 | -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, 6 | { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } 7 | ], 8 | "theme_color": "#ffffff", 9 | "background_color": "#ffffff", 10 | "display": "standalone" 11 | } 12 | -------------------------------------------------------------------------------- /src/models/file.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const ImageFileSchema = new mongoose.Schema({ 4 | timestamp: { type: Date, default: Date.now }, 5 | dataURI: [{ type: String, default: '' }], 6 | blurhash: { type: String, default: '' }, 7 | nsfw: { type: Boolean, default: false } 8 | }); 9 | 10 | export default mongoose.models.Image || mongoose.model('Image', ImageFileSchema); 11 | -------------------------------------------------------------------------------- /src/models/room.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const RoomSchema = new mongoose.Schema({ 4 | pbKey: String, 5 | rid: String, 6 | profanityEnabled: { type: Boolean, default: false }, 7 | messages: [ 8 | { 9 | type: mongoose.Schema.Types.ObjectId, 10 | ref: 'Message' 11 | } 12 | ] 13 | }); 14 | 15 | export default mongoose.models.Room || mongoose.model('Room', RoomSchema); 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://shadcn-svelte.com/schema.json", 3 | "tailwind": { 4 | "css": "src\\global.css", 5 | "baseColor": "zinc" 6 | }, 7 | "aliases": { 8 | "components": "$lib/components", 9 | "utils": "$lib/utils", 10 | "ui": "$lib/components/ui", 11 | "hooks": "$lib/hooks", 12 | "lib": "$lib" 13 | }, 14 | "typescript": true, 15 | "registry": "https://shadcn-svelte.com/registry" 16 | } 17 | -------------------------------------------------------------------------------- /src/models/messages.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const MessageSchema = new mongoose.Schema({ 4 | message: { type: String, default: '' }, 5 | author: { type: String, default: '' }, 6 | image: { 7 | type: mongoose.Schema.Types.ObjectId, 8 | ref: 'Image' 9 | }, 10 | timestamp: { type: Date, default: Date.now } 11 | }); 12 | 13 | export default mongoose.models.Message || mongoose.model('Message', MessageSchema); 14 | -------------------------------------------------------------------------------- /src/lib/utils/colors.ts: -------------------------------------------------------------------------------- 1 | import colors from '$lib/utils/colors.json'; 2 | 3 | export const generateConsistentIndices = (input: string) => { 4 | let hash = 0; 5 | for (let i = 0; i < input.length; i++) { 6 | hash = (hash << 5) - hash + input.charCodeAt(i); 7 | hash |= 0; // Convert to 32bit integer 8 | } 9 | 10 | const index1 = Math.abs(hash % 992); 11 | const index2 = Math.abs(hash % 5); 12 | 13 | return colors[index1][index2]; 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/components/ui/popover/popover-trigger.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/popover/index.ts: -------------------------------------------------------------------------------- 1 | import { Popover as PopoverPrimitive } from "bits-ui"; 2 | import Content from "./popover-content.svelte"; 3 | import Trigger from "./popover-trigger.svelte"; 4 | const Root = PopoverPrimitive.Root; 5 | const Close = PopoverPrimitive.Close; 6 | 7 | export { 8 | Root, 9 | Content, 10 | Trigger, 11 | Close, 12 | // 13 | Root as Popover, 14 | Content as PopoverContent, 15 | Trigger as PopoverTrigger, 16 | Close as PopoverClose, 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-title.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/models/listener.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const ListenerSchema = new mongoose.Schema({ 4 | pbKey: String, 5 | rid: String, 6 | title: String, 7 | webhookUrl: String, 8 | profanityEnabled: { type: Boolean, default: false }, 9 | messages: [ 10 | { 11 | type: mongoose.Schema.Types.ObjectId, 12 | ref: 'Message' 13 | } 14 | ] 15 | }); 16 | 17 | export default mongoose.models.Listener || mongoose.model('Listener', ListenerSchema); 18 | -------------------------------------------------------------------------------- /src/lib/utils/hashing.ts: -------------------------------------------------------------------------------- 1 | export const createShortHash = async (input: string, length: number): Promise => { 2 | const encoder = new TextEncoder(); 3 | const data = encoder.encode(input); 4 | const hash = await window.crypto.subtle.digest('SHA-256', data); 5 | const hashString = btoa(String.fromCharCode(...new Uint8Array(hash))); 6 | 7 | const base64url = hashString.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); 8 | return base64url.substring(0, length); 9 | }; 10 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-description.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-header.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
19 | {@render children?.()} 20 |
21 | -------------------------------------------------------------------------------- /src/routes/api/images/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | 3 | import Image from '../../../models/file.schema'; 4 | 5 | export async function GET({ url }) { 6 | const id = url.searchParams.get('id') ?? ''; 7 | 8 | try { 9 | const image = await Image.findOne({ _id: id }); 10 | if (image) { 11 | return json({ status: 200, body: image }); 12 | } 13 | 14 | return json({ status: 404, body: 'Image not found' }); 15 | } catch (error) { 16 | console.error(error); 17 | return json({ status: 500, body: 'Error fetching image' }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/routes/li/[room]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { page } from '$app/state'; 2 | import type { PageServerLoad } from './$types'; 3 | 4 | export const load = (async ({ params,fetch }) => { 5 | const response = await fetch('/api/profanity', { 6 | method: 'PATCH', 7 | headers: { 8 | 'Content-Type': 'application/json' 9 | }, 10 | body: JSON.stringify({ rid: params.room }) 11 | }); 12 | 13 | const resp = await response.json(); 14 | 15 | return { profanityFilterEnabled: resp.body.profanityEnabled }; 16 | }) satisfies PageServerLoad; -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-footer.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
19 | {@render children?.()} 20 |
21 | -------------------------------------------------------------------------------- /src/lib/profanityFilter.ts: -------------------------------------------------------------------------------- 1 | async function checkProfanity(message: string) { 2 | try { 3 | const res = await fetch('https://vector.profanity.dev', { 4 | method: 'POST', 5 | headers: { 'Content-Type': 'application/json' }, 6 | body: JSON.stringify({ message }) 7 | }); 8 | if (!res.ok) { 9 | throw new Error(`API request failed with status ${res.status}`); 10 | } 11 | return await res.json(); 12 | } catch (error) { 13 | console.error('Error checking profanity:', error); 14 | throw error; 15 | } 16 | } 17 | 18 | export default checkProfanity; 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import raw from 'vite-raw-plugin'; 3 | 4 | import { defineConfig } from 'vitest/config'; 5 | 6 | export default defineConfig({ 7 | server: { 8 | fs: { 9 | allow: [ 10 | // allow the package json 11 | './package.json' 12 | ] 13 | } 14 | }, 15 | plugins: [ 16 | raw({ 17 | fileRegex: /\.md$/ 18 | }), 19 | sveltekit() 20 | ], 21 | optimizeDeps: { 22 | exclude: ['phosphor-svelte'] 23 | }, 24 | test: { 25 | include: ['src/**/*.{test,spec}.{js,ts}'] 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-overlay.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | export type WithoutChild = T extends { child?: any } ? Omit : T; 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | export type WithoutChildren = T extends { children?: any } ? Omit : T; 12 | export type WithoutChildrenOrChild = WithoutChildren>; 13 | export type WithElementRef = T & { ref?: U | null }; 14 | -------------------------------------------------------------------------------- /src/routes/li/[room]/ListenerHeaderConfig.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:svelte/recommended', 7 | 'prettier' 8 | ], 9 | parser: '@typescript-eslint/parser', 10 | plugins: ['@typescript-eslint'], 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020, 14 | extraFileExtensions: ['.svelte'] 15 | }, 16 | env: { 17 | browser: true, 18 | es2017: true, 19 | node: true 20 | }, 21 | overrides: [ 22 | { 23 | files: ['*.svelte'], 24 | parser: 'svelte-eslint-parser', 25 | parserOptions: { 26 | parser: '@typescript-eslint/parser' 27 | } 28 | } 29 | ] 30 | }; 31 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Mongoose } from 'mongoose'; 2 | import { SECRET_MONGO_URI } from '$env/static/private'; 3 | 4 | interface Connection { 5 | isConnected?: number; 6 | } 7 | 8 | const connection: Connection = {}; 9 | 10 | const dbConnect: () => Promise = async () => { 11 | if (connection.isConnected) { 12 | return; 13 | } 14 | if (!SECRET_MONGO_URI) { 15 | throw new Error('Please define the SECRET_MONGO_URI environment variable inside .env'); 16 | } 17 | 18 | const db: Mongoose = await mongoose.connect(SECRET_MONGO_URI); 19 | 20 | connection.isConnected = db.connections[0].readyState; 21 | console.log('DB Connected: ', connection.isConnected); 22 | }; 23 | 24 | export { dbConnect }; 25 | -------------------------------------------------------------------------------- /src/routes/li/[room]/Modals/SettingsModal/PollingDurationSelector.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 |

Refresh interval

18 | 19 | 20 | 21 |

How often the site should refresh for new messages , in seconds. minimum 3 seconds.

22 |
23 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /src/routes/api/stats/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import Listener from '../../../models/listener.schema'; 3 | 4 | export async function GET({ url }) { 5 | const lim = parseInt(url.searchParams.get('lim') ?? '100'); 6 | 7 | const activeUsers = await Listener.find( 8 | { $expr: { $gt: [{ $size: '$messages' }, 4] } } 9 | // { 10 | // messages: { $slice: -lim } 11 | // } 12 | ); 13 | 14 | const totalmessages = activeUsers.reduce((acc, user) => acc + user.messages.length, 0); 15 | const identities = await Listener.distinct('rid'); 16 | 17 | return json({ 18 | status: 200, 19 | body: { 20 | activeUsers: activeUsers.length, 21 | totalMessages: totalmessages, 22 | identities: identities.length 23 | } 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/components/ui/textarea/textarea.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /src/routes/li/[room]/Message/ImageThumbnail.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
19 | 25 | 26 | 29 | 32 | 33 |
34 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/index.ts: -------------------------------------------------------------------------------- 1 | import { Dialog as DialogPrimitive } from "bits-ui"; 2 | 3 | import Title from "./dialog-title.svelte"; 4 | import Footer from "./dialog-footer.svelte"; 5 | import Header from "./dialog-header.svelte"; 6 | import Overlay from "./dialog-overlay.svelte"; 7 | import Content from "./dialog-content.svelte"; 8 | import Description from "./dialog-description.svelte"; 9 | import Trigger from "./dialog-trigger.svelte"; 10 | import Close from "./dialog-close.svelte"; 11 | 12 | const Root = DialogPrimitive.Root; 13 | const Portal = DialogPrimitive.Portal; 14 | 15 | export { 16 | Root, 17 | Title, 18 | Portal, 19 | Footer, 20 | Header, 21 | Trigger, 22 | Overlay, 23 | Content, 24 | Description, 25 | Close, 26 | // 27 | Root as Dialog, 28 | Title as DialogTitle, 29 | Portal as DialogPortal, 30 | Footer as DialogFooter, 31 | Header as DialogHeader, 32 | Trigger as DialogTrigger, 33 | Overlay as DialogOverlay, 34 | Content as DialogContent, 35 | Description as DialogDescription, 36 | Close as DialogClose, 37 | }; 38 | -------------------------------------------------------------------------------- /src/lib/components/ui/toggle-group/toggle-group-item.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 35 | -------------------------------------------------------------------------------- /src/routes/li/[room]/ListenerHeader.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | {#if roomTitle && loadedPair && rid} 24 |
25 | 34 | 41 |
42 | {/if} 43 | -------------------------------------------------------------------------------- /src/lib/components/ui/popover/popover-content.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 29 | 30 | -------------------------------------------------------------------------------- /src/routes/api/profanity/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import Listener from '../../../models/listener.schema'; 3 | 4 | export async function PATCH({ request }) { 5 | const body = await request.json(); 6 | const { rid, pbKey } = body; 7 | const profanityEnabledStatus = body.profanityEnabled; 8 | console.log('PATCH /api/profanity/:pbKey called with body:', body); 9 | try { 10 | let room; 11 | 12 | if (profanityEnabledStatus === undefined) { 13 | // If profanityEnabled is not in the body, find the room and return its state 14 | room = await Listener.findOne({ rid: rid }, { profanityEnabled: 1, _id: 0 }); 15 | } else { 16 | // If profanityEnabled is in the body, update the room with that state 17 | console.log('!!profanityEnabledStatus ', !!profanityEnabledStatus); 18 | 19 | room = await Listener.findOneAndUpdate( 20 | { rid: rid }, 21 | { $set: { profanityEnabled: !!profanityEnabledStatus } }, 22 | { new: true, fields: { profanityEnabled: 1, _id: 0 } } 23 | ); 24 | } 25 | 26 | if (room) { 27 | return json({ status: 200, body: room }); 28 | } else { 29 | return json({ status: 404, body: 'Room not found' }); 30 | } 31 | } catch (error) { 32 | console.error(error); 33 | return json({ status: 500, body: 'Error updating room' }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/utils/pgp.ts: -------------------------------------------------------------------------------- 1 | import * as openpgp from 'openpgp'; 2 | import { createShortHash } from './hashing'; 3 | 4 | export const ResetPgpIdentity = async () => { 5 | const data = { 6 | privateKey: '', 7 | publicKey: '', 8 | revocationCertificate: '', 9 | uniqueString: '' 10 | }; 11 | let resp: any = null; 12 | 13 | const { privateKey, publicKey, revocationCertificate } = await openpgp.generateKey({ 14 | type: 'ecc', 15 | curve: 'curve25519', 16 | userIDs: [{ name: 'Anon', email: 'Sma@robi.work' }], 17 | passphrase: 'super long and hard to guess secret', 18 | format: 'armored' 19 | }); 20 | 21 | data.privateKey = privateKey; 22 | data.publicKey = publicKey; 23 | data.revocationCertificate = revocationCertificate; 24 | data.uniqueString = await createShortHash(privateKey + publicKey, 12); 25 | 26 | // Save the data to the server 27 | const response = await fetch('/api/pgp', { 28 | method: 'POST', 29 | headers: { 30 | 'Content-Type': 'application/json' 31 | }, 32 | body: JSON.stringify({ 33 | pbKey: data.publicKey, 34 | rid: data.uniqueString 35 | }) 36 | }); 37 | 38 | resp = await response.json(); 39 | 40 | if (!resp) return; 41 | 42 | if (resp.error) { 43 | console.log(resp.message); 44 | } else { 45 | console.log(resp.message); 46 | return data; 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/lib/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import MessageFeedback from '../_components/MessageFeedback.svelte'; 2 | import { mount, unmount } from 'svelte'; 3 | 4 | // Break a long string into some max length array of strings 5 | export function breakString(str: string, maxLength: number) { 6 | const parts = []; 7 | for (let i = 0; i < str.length; i += maxLength) { 8 | parts.push(str.substring(i, i + maxLength)); 9 | } 10 | return parts; 11 | } 12 | 13 | // show feedback on the given component 14 | export function showMessageFeedback( 15 | variant: 'error' | 'default', 16 | message: string, 17 | containerId: string 18 | ) { 19 | // Dynamically create a div element to mount the MessageFeedback component 20 | const container = document.getElementById(containerId); 21 | 22 | // Check if the container element exists 23 | if (!container) { 24 | console.error(`Feedback container with ID '${containerId}' not found.`); 25 | return; // Stop the operation if container is not found 26 | } 27 | 28 | // Mount the MessageFeedback component to the container 29 | const feedback = mount(MessageFeedback, { 30 | target: container, 31 | props: { variant, message } 32 | }); 33 | 34 | // Remove the dynamically created div from the DOM after a delay (optional) 35 | setTimeout(() => { 36 | unmount(feedback); 37 | }, 5000); // Remove after 5 seconds (adjust as needed) 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/components/ui/toggle-group/toggle-group.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | 32 | 36 | 48 | -------------------------------------------------------------------------------- /src/routes/i/IdentityPill.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 39 | -------------------------------------------------------------------------------- /src/routes/api/title/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import Listener from '../../../models/listener.schema'; 3 | 4 | interface Listener { 5 | pbKey: string; 6 | rid: string; 7 | title: string; 8 | } 9 | 10 | export async function GET(request) { 11 | const rid = request.url.searchParams.get('rid'); 12 | console.log('GET /api/title/:rid called with rid:', rid, typeof rid); 13 | try { 14 | const room = await Listener.findOne({ rid: rid }, { title: 1, rid: 1 }); 15 | 16 | if (room) { 17 | return json({ status: 200, body: room }); 18 | } else { 19 | return json({ status: 404, body: 'Room not found' }); 20 | } 21 | } catch (error) { 22 | console.error('GET error:', error); 23 | return json({ status: 500, body: 'Error fetching room title' }); 24 | } 25 | } 26 | 27 | export async function PATCH({ request }) { 28 | const body = await request.json(); 29 | const { pbKey, title } = body as Listener; 30 | try { 31 | 32 | const room = await Listener.findOneAndUpdate( 33 | { pbKey: pbKey }, 34 | { $set: { title: title } }, 35 | { new: true } 36 | ); 37 | if (room) { 38 | return json({ status: 200, body: room }); 39 | } else { 40 | return json({ status: 404, body: 'Room not found' }); 41 | } 42 | } catch (error) { 43 | console.error('PATCH error:', error); 44 | return json({ status: 500, body: 'Error updating room' }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/routes/MessagesButton.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
18 | 22 | 23 | 31 | 37 |   38 | 39 | 44 | {loadedPair.uniqueString} 45 | 46 | 47 |
48 | -------------------------------------------------------------------------------- /src/routes/li/[room]/Header/CopyLink.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 43 | -------------------------------------------------------------------------------- /src/lib/components/ui/checkbox/checkbox.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 27 | {#snippet children({ checked, indeterminate })} 28 |
29 | {#if checked} 30 | 31 | {:else if indeterminate} 32 | 33 | {/if} 34 |
35 | {/snippet} 36 |
37 | -------------------------------------------------------------------------------- /src/lib/components/ui/dialog/dialog-content.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 33 | {@render children?.()} 34 | {#if showCloseButton} 35 | 38 | 39 | Close 40 | 41 | {/if} 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/routes/Stats.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 | {#if statsLoaded} 45 | 46 | {Math.round($activeUsers) ?? ''} 47 | {stats.activeUsers != 1 ? 'Active users' : 'Active user'} , 48 | {Math.round($identities)} 49 | {stats.identities != 1 ? 'Identities' : 'Identity'} , 50 | {Math.round($totalMessages)} 51 | {stats.totalMessages != 1 ? 'Msgs' : 'Msg'} and counting... 52 | 53 | {:else} 54 | Loading Stats... 55 | {/if} 56 | -------------------------------------------------------------------------------- /src/routes/PGPPowerUser.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | {#if powerUser} 25 | 41 | {/if} 42 | 43 | 63 | -------------------------------------------------------------------------------- /src/lib/components/ui/badge/badge.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | 41 | 49 | {@render children?.()} 50 | 51 | -------------------------------------------------------------------------------- /src/lib/components/ui/toggle/toggle.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 45 | 46 | 53 | -------------------------------------------------------------------------------- /src/routes/li/[room]/Modals/SettingsModal/ProfanityToggle.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 |
39 |

Profanity filter

40 |
41 | { 45 | updateProf(value == 'on' ? true : false); 46 | }} 47 | > 48 | 52 |

Profanity filter off

54 | 58 |

Profanity filter on

60 |
61 |
62 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
28 | 29 |

33 | Welcome to S.M.A 34 |

35 | 36 |
Send Messages Anon
37 | 38 |
39 | 40 | {#if loadedPair} 41 |
45 | 49 | 50 | 51 |
52 | {/if} 53 | 54 | 59 |
60 | -------------------------------------------------------------------------------- /src/lib/components/ui/input/input.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | {#if type === "file"} 23 | 37 | {:else} 38 | 51 | {/if} 52 | -------------------------------------------------------------------------------- /src/routes/li/[room]/Modals/SettingsModal.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 35 | 36 | 37 | 38 | 39 | Settings 40 | 41 | Manage Settings of this room 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | 55 |
56 |
57 |
58 |
59 | -------------------------------------------------------------------------------- /src/routes/li/[room]/Header/Mute.svelte: -------------------------------------------------------------------------------- 1 | 67 | 68 | 82 | -------------------------------------------------------------------------------- /src/routes/li/[room]/Message/BlurhashThumbnail.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 | 41 | 44 | . 45 | 46 | 47 | 54 | 55 |
56 | {#if imageBase64} 57 | View... 58 | {:else} 59 |
60 | {/if} 61 |
62 | 63 | 68 | 69 | 70 |
71 |
72 |
73 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 27 | 28 | 29 | 33 | 34 | 35 | S.M.A 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
%sveltekit.body%
46 | 47 | 48 | 72 | 73 | -------------------------------------------------------------------------------- /src/routes/li/[room]/Modals/SettingsModal/WebhookSettings.svelte: -------------------------------------------------------------------------------- 1 | 61 | 62 |
63 |

Webhook Settings

64 | 65 |
66 | 72 | 73 |
74 | 78 |
79 |
80 | {#if validationError} 81 | {validationError} 82 | {/if} 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sma", 3 | "version": "0.5.0", 4 | "private": true, 5 | "license": "GPL-3.0-only", 6 | "scripts": { 7 | "dev": "vite dev", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "test": "npm run test:integration && npm run test:unit", 11 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 | "lint": "prettier --check . && eslint .", 14 | "format": "prettier --write --plugin prettier-plugin-svelte --plugin prettier-plugin-tailwindcss .", 15 | "formatter": "prettier -v", 16 | "test:integration": "playwright test", 17 | "test:unit": "vitest", 18 | "prepare": "husky" 19 | }, 20 | "devDependencies": { 21 | "@internationalized/date": "^3.8.1", 22 | "@lucide/svelte": "^0.515.0", 23 | "@openpgp/web-stream-tools": "0.0.11-patch-0", 24 | "@playwright/test": "^1.55.0", 25 | "@sveltejs/adapter-auto": "^3.0.0", 26 | "@sveltejs/kit": "^2.5.27", 27 | "@sveltejs/vite-plugin-svelte": "^4.0.0", 28 | "@types/uuid": "^10.0.0", 29 | "@typescript-eslint/eslint-plugin": "^5.62.0", 30 | "@typescript-eslint/parser": "^5.62.0", 31 | "bits-ui": "^2.8.6", 32 | "clsx": "^2.1.1", 33 | "eslint": "^8.57.1", 34 | "eslint-config-prettier": "^8.10.2", 35 | "eslint-plugin-svelte": "^2.46.1", 36 | "husky": "^9.1.7", 37 | "prettier": "^3.6.2", 38 | "prettier-plugin-svelte": "^3.4.0", 39 | "prettier-plugin-tailwindcss": "^0.6.14", 40 | "svelte": "^5.0.0", 41 | "svelte-check": "^4.0.0", 42 | "tailwind-merge": "^3.3.1", 43 | "tailwind-variants": "^1.0.0", 44 | "tslib": "^2.8.1", 45 | "tw-animate-css": "^1.3.7", 46 | "typescript": "^5.9.2", 47 | "vite": "^5.4.4", 48 | "vitest": "^1.0.0" 49 | }, 50 | "type": "module", 51 | "dependencies": { 52 | "@tailwindcss/postcss": "^4.1.12", 53 | "blurhash": "^2.0.5", 54 | "dotenv": "^16.6.1", 55 | "modern-screenshot": "^4.6.6", 56 | "mongoose": "^8.17.2", 57 | "openpgp": "^5.11.3", 58 | "phosphor-svelte": "^2.0.1", 59 | "postcss": "^8.5.6", 60 | "pretty-ms": "^9.2.0", 61 | "tailwindcss": "^4.1.12", 62 | "theme-change": "^2.5.0", 63 | "uuid": "^10.0.0", 64 | "vite-raw-plugin": "^1.0.2" 65 | }, 66 | "pnpm": { 67 | "onlyBuiltDependencies": [ 68 | "@sveltejs/kit", 69 | "svelte-preprocess" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/routes/api/webhook/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import Listener from '../../../models/listener.schema'; 3 | 4 | export async function PATCH({ request }) { 5 | const body = await request.json(); 6 | const { rid, webhookUrl } = body; 7 | console.log('Updating webhook with:', { rid, webhookUrl }); 8 | 9 | if (!rid || !webhookUrl) { 10 | return json({ status: 400, body: 'Missing required fields' }); 11 | } 12 | 13 | try { 14 | // First verify the listener exists 15 | const existingListener = await Listener.findOne({ rid }); 16 | 17 | if (!existingListener) { 18 | return json({ status: 404, body: 'Listener not found' }); 19 | } 20 | 21 | // Update the webhook URL 22 | existingListener.webhookUrl = webhookUrl; 23 | await existingListener.save(); 24 | 25 | return json({ 26 | status: 200, 27 | body: { 28 | message: 'Webhook URL updated successfully', 29 | listener: existingListener 30 | } 31 | }); 32 | } catch (error) { 33 | console.error('Error updating webhook URL:', error); 34 | return json({ status: 500, body: 'Error updating webhook URL' }); 35 | } 36 | } 37 | 38 | export async function DELETE({ request }) { 39 | const body = await request.json(); 40 | const { rid } = body; 41 | console.log('Updating webhook for:', { rid }); 42 | 43 | if (!rid) { 44 | return json({ status: 400, body: 'Missing required fields' }); 45 | } 46 | 47 | try { 48 | // First verify the listener exists 49 | const existingListener = await Listener.findOne({ rid }); 50 | 51 | if (!existingListener) { 52 | return json({ status: 404, body: 'Listener not found' }); 53 | } 54 | 55 | // Update the webhook URL 56 | existingListener.webhookUrl = ""; 57 | await existingListener.save(); 58 | 59 | return json({ 60 | status: 200, 61 | body: { 62 | message: 'Webhook URL updated successfully', 63 | listener: existingListener 64 | } 65 | }); 66 | } catch (error) { 67 | console.error('Error updating webhook URL:', error); 68 | return json({ status: 500, body: 'Error updating webhook URL' }); 69 | } 70 | } 71 | 72 | 73 | export async function GET({ url }) { 74 | const rid = url.searchParams.get('rid'); 75 | 76 | try { 77 | const listener = await Listener.findOne({ rid }); 78 | 79 | if (listener) { 80 | return json({ status: 200, body: { webhookUrl: listener.webhookUrl } }); 81 | } else { 82 | return json({ status: 404, body: 'Listener not found' }); 83 | } 84 | } catch (error) { 85 | console.error(error); 86 | return json({ status: 500, body: 'Error retrieving webhook URL' }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/routes/i/+page.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
28 |
31 | 32 | 33 | 34 | 35 | 36 |

37 | Manage rooms & identities 38 |

39 | {#if loadedPair} 40 | 41 | 42 | 43 | {/if} 44 | 45 | 46 | 71 | 72 |
73 | 74 |
75 | 76 |
77 |
78 | -------------------------------------------------------------------------------- /src/lib/components/ui/button/button.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | 55 | 56 | {#if href} 57 | 67 | {@render children?.()} 68 | 69 | {:else} 70 | 80 | {/if} 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # S.M.A (Send Messages Anonymously) 2 | 3 |
4 | svelte badge 5 | typescript badge 6 | node.js badge 7 | mongodb badge 8 |
9 | 10 | ## Description 11 | 12 | S.M.A is a web application that allows users to send messages to each other anonymously. It is built using Svelte and TypeScript, with a Node.js backend and MongoDB database. 13 | 14 | ## Features 15 | 16 | - Send and receive messages anonymously 17 | - Toggleable profanity filter 18 | 19 | ## Live Website 20 | 21 | You can access the live website at [sma.robi.work](https://sma.robi.work/) 22 | 23 | ## Prerequisites 24 | 25 | Before you begin, ensure you have met the following requirements: 26 | 27 | - You have installed the latest version of Node.js and pnpm 28 | - You have a MongoDB database set up. 29 | 30 | ## Installation 31 | 32 | ### Clone the repository: 33 | 34 | ```bash 35 | git clone https://github.com/RobiMez/sma.git 36 | cd sma 37 | ``` 38 | 39 | ### Install the dependencies: 40 | 41 | Using pnpm ( Preferred ): 42 | 43 | ```bash 44 | pnpm i 45 | ``` 46 | 47 | ### Create a `.env` file in the root directory of the project, and add the following line: 48 | 49 | ```bash 50 | SECRET_MONGO_URI="your_mongodb_connection_string" 51 | ``` 52 | 53 | Replace `your_mongodb_connection_string` with your actual MongoDB connection string. 54 | 55 | ### Start the development server: 56 | 57 | Using pnpm: 58 | 59 | ```bash 60 | pnpm dev 61 | ``` 62 | 63 | You can find the site at `http://localhost:5173/`. 64 | 65 | ## Contributing 66 | 67 | When working on the project , use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). 68 | Examples of conventional commits are: 69 | 70 | - `feat(polling): Optimized polling algorithm` 71 | - `fix(ui): Message box doesnt kill the site anymore` 72 | - `refactor(api): New api endpoint for messages` 73 | - `docs: updated documentation` 74 | - `chore: smol fixes` 75 | - `test: added tests for the new feature` 76 | - `style: fixed the styling of the message box` 77 | - `ci: added ci/cd pipeline` 78 | - `perf: optimized the code` 79 | - `revert: reverted the last commit` 80 | - `build: added new build system` 81 | 82 | You can use the following command to commit your changes and follow the conventional commits format: 83 | 84 | ```bash 85 | pnpm commit 86 | ``` 87 | 88 | Contributions are welcome! 89 | Please fork the repository and submit a pull request. 90 | I'll review it as soon as possible. 91 | 92 | ## Credits 93 | 94 | S.M.A was created by [Robi](https://github.com/RobiMez) and Improved with the help of [doniverse](https://github.com/doniverse) and [pilanop](https://github.com/pilanop) 95 | 96 | ## License 97 | 98 | This project is licensed under the GNU General Public License v3.0 99 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
29 | {#if browser} 30 |
37 | 38 | 39 |
{ 45 | if (systemPeek) return; 46 | document.documentElement.classList.toggle('dark'); 47 | if (document.documentElement.classList.contains('dark')) { 48 | localStorage.theme = 'dark'; 49 | themeDark = true; 50 | } else { 51 | localStorage.theme = 'light'; 52 | themeDark = false; 53 | } 54 | }} 55 | > 56 | {#if themeDark} 57 | 58 | {:else} 59 | 60 | {/if} 61 | 64 | 65 | 97 |
98 |
99 | {/if} 100 | {@render children?.()} 101 |
102 | -------------------------------------------------------------------------------- /src/routes/api/pgp/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import Listener from '../../../models/listener.schema'; 3 | import Message from '../../../models/messages.schema'; 4 | import Image from '../../../models/file.schema'; 5 | 6 | interface Listener { 7 | pbKey: string; 8 | rid: string; 9 | } 10 | 11 | async function sendWebhookNotification(webhookUrl: string, message: any) { 12 | try { 13 | await fetch(webhookUrl, { 14 | method: 'POST', 15 | headers: { 16 | 'Content-Type': 'application/json' 17 | }, 18 | body: JSON.stringify({ 19 | content: message.message, 20 | timestamp: message.createdAt, 21 | author: message.author 22 | }) 23 | }); 24 | } catch (error) { 25 | console.error('Webhook notification failed:', error); 26 | } 27 | } 28 | 29 | async function saveImage(imageData: { dataURI: string; blurhash: string; nsfw: boolean }) { 30 | if (!imageData.dataURI.length) return null; 31 | 32 | const image = new Image({ 33 | dataURI: imageData.dataURI, 34 | blurhash: imageData.blurhash, 35 | nsfw: imageData.nsfw 36 | }); 37 | await image.save(); 38 | return image; 39 | } 40 | 41 | async function createMessage(messageText: string, imageId: string | null, author: string) { 42 | const message = new Message({ 43 | message: messageText, 44 | image: imageId, 45 | author 46 | }); 47 | await message.save(); 48 | return message; 49 | } 50 | 51 | async function updateListenerWithMessage(recipientId: string, messageId: string) { 52 | return await Listener.findOneAndUpdate( 53 | { rid: recipientId }, 54 | { 55 | $push: { 56 | messages: { 57 | $each: [messageId], 58 | $position: 0 59 | } 60 | } 61 | }, 62 | { new: true } 63 | ); 64 | } 65 | 66 | export async function GET({ url }) { 67 | const rid = url.searchParams.get('r') ?? ''; 68 | const lim = parseInt(url.searchParams.get('lim') ?? '100'); 69 | 70 | const user = await Listener.findOne({ rid }, { messages: { $slice: -lim } }).populate({ 71 | path: 'messages', 72 | populate: { 73 | path: 'image', 74 | model: 'Image', 75 | select: '-dataURI' 76 | } 77 | }); 78 | if (user) { 79 | return json({ status: 200, body: user }); 80 | } 81 | 82 | return json({ status: 404, body: 'Public key not found' }); 83 | } 84 | 85 | export async function PATCH({ request }) { 86 | const { message, imageData, r: author, p: recipientId } = await request.json(); 87 | 88 | try { 89 | const image = await saveImage(imageData); 90 | const newMessage = await createMessage(message, image?._id ?? null, author); 91 | const listener = await updateListenerWithMessage(recipientId, newMessage._id); 92 | 93 | if (listener?.webhookUrl) { 94 | await sendWebhookNotification(listener.webhookUrl, newMessage); 95 | } 96 | 97 | return listener 98 | ? json({ status: 200, body: listener }) 99 | : json({ status: 404, body: 'Listener not found' }); 100 | } catch (error) { 101 | console.error(error); 102 | return json({ status: 500, body: 'Error updating listener' }); 103 | } 104 | } 105 | 106 | export async function POST({ request }) { 107 | const body = await request.json(); 108 | const { pbKey, rid } = body as unknown as Listener; 109 | 110 | if (!pbKey || !rid) 111 | return json({ status: 500, body: 'Error saving listener : supply pbkey & rid' }); 112 | 113 | const newListener = new Listener({ 114 | pbKey, 115 | rid, 116 | title: rid, 117 | profanityEnabled: true, 118 | messages: [] 119 | }); 120 | 121 | try { 122 | await newListener.save(); 123 | return json({ status: 200, body: 'Listener saved successfully' }); 124 | } catch (error) { 125 | console.error(error); 126 | return json({ status: 500, body: 'Error saving listener' }); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/global copy.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Lexend:wght@100;200;300;400;500;600;700;800;900&display=swap') 2 | layer(base); 3 | @import 'tailwindcss'; 4 | 5 | @custom-variant dark (&:where(.dark, .dark *)); 6 | 7 | @theme { 8 | --text-*: initial; 9 | --text-xs: 0.56rem; 10 | --text-sm: 0.75rem; 11 | --text-base: 1rem; 12 | --text-lg: 1.33rem; 13 | --text-xl: 1.78rem; 14 | --text-2xl: 2.37rem; 15 | --text-3xl: 3.16rem; 16 | --text-4xl: 4.21rem; 17 | --text-5xl: 5.61rem; 18 | --text-6xl: 7.48rem; 19 | --text-7xl: 9.97rem; 20 | 21 | --color-*: initial; 22 | --color-dark-content: #edede9; 23 | --color-dark-content-secondary: #f5ebe0; 24 | --color-dark-base: #282d2d; 25 | 26 | --color-dark-50: oklch(86.53% 0.00658 197.86); 27 | --color-dark-100: oklch(82.734% 0.00774 197.677); 28 | --color-dark-200: oklch(74.93% 0.01127 197.334); 29 | --color-dark-300: oklch(66.926% 0.01495 197.083); 30 | --color-dark-400: oklch(58.502% 0.01655 196.932); 31 | --color-dark-500: oklch(49.381% 0.01357 196.95); 32 | --color-dark-600: oklch(39.815% 0.01043 196.982); 33 | --color-dark-700: oklch(29.225% 0.00706 197.034); 34 | --color-dark-800: oklch(25.207% 0.00587 197.057); 35 | --color-dark-900: oklch(21.894% 0.00457 197.126); 36 | --color-dark-950: oklch(19.694% 0.00468 197.044); 37 | 38 | --color-light-content: #272d2d; 39 | --color-light-content-secondary: #5e503f; 40 | --color-light-base: #edede9; 41 | 42 | --color-light-50: oklch(97.558% 0.00254 107.115); 43 | --color-light-100: oklch(94.499% 0.00522 106.819); 44 | --color-light-200: oklch(87.799% 0.00945 100.101); 45 | --color-light-300: oklch(80.1% 0.01785 106.882); 46 | --color-light-400: oklch(71.232% 0.02407 101.539); 47 | --color-light-500: oklch(64.807% 0.02771 97.319); 48 | --color-light-600: oklch(60.052% 0.02456 89.876); 49 | --color-light-700: oklch(52.843% 0.02067 89.451); 50 | --color-light-800: oklch(46.462% 0.0165 88.773); 51 | --color-light-900: oklch(40.359% 0.01117 78.151); 52 | --color-light-950: oklch(14.434% 0.00218 106.792); 53 | 54 | --font-*: initial; 55 | --font-sans: Lexend, sans-serif; 56 | } 57 | 58 | /* 59 | The default border color has changed to `currentcolor` in Tailwind CSS v4, 60 | so we've added these compatibility styles to make sure everything still 61 | looks the same as it did with Tailwind CSS v3. 62 | 63 | If we ever want to remove these styles, we need to add an explicit border 64 | color utility to any element that depends on these defaults. 65 | */ 66 | @layer base { 67 | *, 68 | ::after, 69 | ::before, 70 | ::backdrop, 71 | ::file-selector-button { 72 | border-color: var(--color-gray-200, currentcolor); 73 | } 74 | } 75 | 76 | .loader { 77 | width: 15px; 78 | aspect-ratio: 1; 79 | border-radius: 50%; 80 | animation: l5 1s infinite linear alternate; 81 | } 82 | @keyframes l5 { 83 | 0% { 84 | box-shadow: 85 | 20px 0 #000, 86 | -20px 0 #0002; 87 | background: #000; 88 | } 89 | 33% { 90 | box-shadow: 91 | 20px 0 #000, 92 | -20px 0 #0002; 93 | background: #0002; 94 | } 95 | 66% { 96 | box-shadow: 97 | 20px 0 #0002, 98 | -20px 0 #000; 99 | background: #0002; 100 | } 101 | 100% { 102 | box-shadow: 103 | 20px 0 #0002, 104 | -20px 0 #000; 105 | background: #000; 106 | } 107 | } 108 | 109 | /* Light & dark mode auto styles */ 110 | 111 | h1, 112 | h2, 113 | h3, 114 | h4, 115 | h5, 116 | h6, 117 | p, 118 | small { 119 | @apply text-light-content transition-colors duration-200 dark:text-dark-content; 120 | } 121 | 122 | body { 123 | @apply bg-light-base text-light-content transition-colors duration-200 124 | dark:bg-dark-base dark:text-dark-content; 125 | } 126 | 127 | a { 128 | @apply text-light-content underline decoration-light-content-secondary/30 decoration-2 dark:text-dark-content dark:decoration-dark-content-secondary/30; 129 | } 130 | -------------------------------------------------------------------------------- /src/routes/i/IdentityList.svelte: -------------------------------------------------------------------------------- 1 | 46 | 47 |
48 |
49 | {#each keyPairs as identity, i} 50 |
51 | 52 |
53 | {/each} 54 | 55 |
56 |
57 | 58 | 59 | 60 | {#if selectedIdentity} 61 | 62 | Identity 63 | 64 | Load a new identity here, below is the details of the identity. 65 | 66 | 67 | {#if selectedIdentity.uniqueString} 68 | {@const color = generateConsistentIndices(selectedIdentity.uniqueString)} 69 | 70 |
74 |   75 |
76 | 77 | {selectedIdentity.uniqueString} 78 | 79 |
80 | {/if} 81 |
82 |
83 |
84 |
85 | 86 | 87 | 88 | {selectedIdentity.pbKey} 89 | 90 | 91 | {selectedIdentity.prKey} 92 | 93 | 94 | {selectedIdentity.RC} 95 | 96 | 97 | 98 | 99 | 120 | 121 | {/if} 122 |
123 |
124 | -------------------------------------------------------------------------------- /src/lib/utils/localStorage.ts: -------------------------------------------------------------------------------- 1 | import type { IKeyPairs } from '$lib/types'; 2 | import { ResetPgpIdentity } from './pgp'; 3 | 4 | /** 5 | * Clears all items from localStorage except for a few critical items: 6 | * - loadedPair: Currently active PGP key pair 7 | * - keyPairs: All stored PGP key pairs 8 | 9 | * - theme: Current UI theme setting 10 | * 11 | * This is used to clean up localStorage while preserving essential application state. 12 | * The function first saves the critical items, clears everything, then restores just those items. 13 | */ 14 | export const clearLS = () => { 15 | // Check if we're running in a browser environment 16 | if (typeof window === 'undefined') { 17 | console.log('clearLS: Not in browser environment, returning early'); 18 | return; 19 | } 20 | 21 | console.log('clearLS: Starting localStorage cleanup'); 22 | 23 | // Save critical items before clearing 24 | const loadedPair = localStorage.getItem('loadedPair'); 25 | const keyPairs = localStorage.getItem('keyPairs'); 26 | 27 | const theme = localStorage.getItem('theme'); 28 | 29 | console.log('clearLS: Saved critical items:', { 30 | hasLoadedPair: loadedPair, 31 | hasKeyPairs: keyPairs, 32 | hasTheme: theme 33 | }); 34 | 35 | // Clear all localStorage 36 | localStorage.clear(); 37 | console.log('clearLS: Cleared localStorage'); 38 | 39 | // Restore critical items if they existed 40 | if (loadedPair) { 41 | localStorage.setItem('loadedPair', loadedPair); 42 | console.log('clearLS: Restored loadedPair'); 43 | } 44 | if (keyPairs) { 45 | localStorage.setItem('keyPairs', keyPairs); 46 | console.log('clearLS: Restored keyPairs'); 47 | } 48 | 49 | if (theme) { 50 | localStorage.setItem('theme', theme); 51 | console.log('clearLS: Restored theme'); 52 | } 53 | 54 | console.log('clearLS: Cleanup complete'); 55 | }; 56 | 57 | export const saveToLS = (prKey: string, pbKey: string, RC: string, uniqueString: string) => { 58 | if (typeof window === 'undefined') return; 59 | const existingEntries = localStorage.getItem('keyPairs'); 60 | const keyPairs = existingEntries ? JSON.parse(existingEntries) : {}; 61 | keyPairs[uniqueString] = { prKey, pbKey, RC, uniqueString }; 62 | localStorage.setItem('keyPairs', JSON.stringify(keyPairs)); 63 | }; 64 | 65 | export const getFromLS = async (uniqueString: string) => { 66 | if (typeof window === 'undefined') return; 67 | const existingEntries = localStorage.getItem('keyPairs'); 68 | if (!existingEntries) return; 69 | const keyPairs = JSON.parse(existingEntries); 70 | const keyPair = keyPairs[uniqueString]; 71 | if (!keyPair) return; 72 | return keyPair as IKeyPairs; 73 | }; 74 | 75 | export const getAllFromLS = async () => { 76 | if (typeof window === 'undefined') return []; 77 | let existingEntries = localStorage.getItem('keyPairs'); 78 | // if no keypair in keypairs , generate one and set it 79 | if (!existingEntries) { 80 | const newPair = await ResetPgpIdentity(); 81 | if (!newPair) throw new Error('Error generating genesis identity in getAllFromLS'); 82 | saveToLS( 83 | newPair?.privateKey, 84 | newPair?.publicKey, 85 | newPair?.revocationCertificate, 86 | newPair?.uniqueString 87 | ); 88 | 89 | existingEntries = localStorage.getItem('keyPairs'); 90 | } 91 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 92 | const keyPairs = JSON.parse(existingEntries!); 93 | 94 | return Object.values(keyPairs) as IKeyPairs[]; 95 | }; 96 | 97 | export const postPgpKey = async (pbKey: string, rid: string) => { 98 | const response = await fetch('/api/pgp', { 99 | method: 'POST', 100 | headers: { 101 | 'Content-Type': 'application/json' 102 | }, 103 | body: JSON.stringify({ 104 | pbKey, 105 | rid: rid 106 | }) 107 | }); 108 | 109 | const resp = await response.json(); 110 | 111 | if (resp.error) { 112 | console.log(resp.message); 113 | } else { 114 | console.log(resp.message); 115 | } 116 | }; 117 | 118 | // Function to load a key pair by uniqueString and store it in local storage 119 | export const loadPair = (uniqueString: string) => { 120 | if (typeof window === 'undefined') return; 121 | const existingEntries = localStorage.getItem('keyPairs'); 122 | if (!existingEntries) return; 123 | const keyPairs = JSON.parse(existingEntries); 124 | const keyPair = keyPairs[uniqueString]; 125 | if (!keyPair) return; 126 | localStorage.setItem('loadedPair', JSON.stringify(keyPair)); 127 | }; 128 | 129 | // Utility function to fetch the loaded pair from local storage 130 | export const getLoadedPairFromLS = async () => { 131 | if (typeof window === 'undefined') return undefined; 132 | const loadedPair = localStorage.getItem('loadedPair'); 133 | // if there is no localstorage item with the key 'loadedPair' 134 | // set the first keypair in localstorage as the loaded pair 135 | if (!loadedPair) { 136 | const keyPairs = await getAllFromLS(); 137 | // make a new keypair if none exist in ls 138 | 139 | if (!keyPairs) return undefined; 140 | localStorage.setItem('loadedPair', JSON.stringify(keyPairs[0])); 141 | return keyPairs[0]; 142 | } 143 | 144 | return JSON.parse(loadedPair) as IKeyPairs | undefined; 145 | }; 146 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Lexend:wght@100;200;300;400;500;600;700;800;900&display=swap'); 2 | @import "tailwindcss"; 3 | 4 | @import "tw-animate-css"; 5 | 6 | @custom-variant dark (&:is(.dark *)); 7 | 8 | :root { 9 | 10 | --radius: 0rem; 11 | 12 | --color-forest-50: oklch(88.53% 0.00658 197.86); 13 | --color-forest-100: oklch(82.734% 0.00774 197.677); 14 | --color-forest-200: oklch(74.93% 0.01127 197.334); 15 | --color-forest-300: oklch(66.926% 0.01495 197.083); 16 | --color-forest-400: oklch(58.502% 0.01655 196.932); 17 | --color-forest-500: oklch(49.381% 0.01357 196.95); 18 | --color-forest-600: oklch(39.815% 0.01043 197); 19 | --color-forest-700: oklch(29.225% 0.00706 197); 20 | --color-forest-800: oklch(25.207% 0.00587 197); 21 | --color-forest-900: oklch(21.894% 0.00457 197.1); 22 | --color-forest-950: oklch(19.694% 0.00468 197); 23 | 24 | --color-sand-50: oklch(97.558% 0.00254 107.115); 25 | --color-sand-100: oklch(94.499% 0.00522 106.819); 26 | --color-sand-200: oklch(87.799% 0.00945 100.101); 27 | --color-sand-300: oklch(80.1% 0.01785 106.882); 28 | --color-sand-400: oklch(71.232% 0.02407 101.539); 29 | --color-sand-500: oklch(64.807% 0.02771 97.319); 30 | --color-sand-600: oklch(60.052% 0.02456 89.876); 31 | --color-sand-700: oklch(52.843% 0.02067 89.451); 32 | --color-sand-800: oklch(46.462% 0.0165 88.773); 33 | --color-sand-900: oklch(40.359% 0.01117 78.151); 34 | --color-sand-950: oklch(14.434% 0.00218 106.792); 35 | 36 | /* #region variables */ 37 | --background: var(--color-sand-50); 38 | --foreground: var(--color-sand-800); 39 | 40 | --card: var(--color-sand-100); 41 | --card-foreground: var(--color-sand-800); 42 | 43 | --popover: var(--color-sand-100); 44 | --popover-foreground: var(--color-sand-800); 45 | 46 | --primary: var(--color-sand-800); 47 | --primary-foreground: var(--color-sand-50); 48 | 49 | --secondary: var(--color-forest-50); 50 | --secondary-foreground: var(--color-forest-600); 51 | 52 | --muted: var(--color-sand-100); 53 | --muted-foreground: var(--color-sand-600); 54 | 55 | --accent: oklch(0.967 0.001 286.375); 56 | --accent-foreground: oklch(0.21 0.006 285.885); 57 | 58 | --destructive: oklch(48.752% 0.10551 19.391); 59 | --border: var(--color-sand-600); 60 | --input: oklch(0.92 0.004 286.32); 61 | --ring: oklch(0.705 0.015 286.067); 62 | 63 | --chart-1: oklch(0.646 0.222 41.116); 64 | --chart-2: oklch(0.6 0.118 184.704); 65 | --chart-3: oklch(0.398 0.07 227.392); 66 | --chart-4: oklch(0.828 0.189 84.429); 67 | --chart-5: oklch(0.769 0.188 70.08); 68 | 69 | --sidebar: oklch(0.985 0 0); 70 | --sidebar-foreground: oklch(0.141 0.005 285.823); 71 | --sidebar-primary: oklch(0.21 0.006 285.885); 72 | --sidebar-primary-foreground: oklch(0.985 0 0); 73 | --sidebar-accent: oklch(0.967 0.001 286.375); 74 | --sidebar-accent-foreground: oklch(0.21 0.006 285.885); 75 | --sidebar-border: oklch(0.92 0.004 286.32); 76 | --sidebar-ring: oklch(0.705 0.015 286.067); 77 | /* #endregion */ 78 | } 79 | 80 | .dark { 81 | --background: var(--color-forest-700); 82 | --foreground: var(--color-forest-50); 83 | 84 | --card: var(--color-forest-800); 85 | --card-foreground: var(--color-forest-50); 86 | 87 | --popover: oklch(0.21 0.006 285.885); 88 | --popover-foreground: oklch(0.985 0 0); 89 | 90 | --primary: var(--color-forest-200); 91 | --primary-foreground: oklch(0.21 0.006 285.885); 92 | 93 | --secondary: oklch(0.274 0.006 286.033); 94 | --secondary-foreground: oklch(0.985 0 0); 95 | 96 | --muted: var(--color-forest-800); 97 | --muted-foreground: oklch(0.705 0.015 286.067); 98 | 99 | --accent: oklch(0.274 0.006 286.033); 100 | --accent-foreground: oklch(0.985 0 0); 101 | --destructive: oklch(0.704 0.191 22.216); 102 | 103 | --border: var(--color-forest-600); 104 | --input: oklch(1 0 0 / 15%); 105 | --ring: oklch(0.552 0.016 285.938); 106 | --chart-1: oklch(0.488 0.243 264.376); 107 | --chart-2: oklch(0.696 0.17 162.48); 108 | --chart-3: oklch(0.769 0.188 70.08); 109 | --chart-4: oklch(0.627 0.265 303.9); 110 | --chart-5: oklch(0.645 0.246 16.439); 111 | --sidebar: oklch(0.21 0.006 285.885); 112 | --sidebar-foreground: oklch(0.985 0 0); 113 | --sidebar-primary: oklch(0.488 0.243 264.376); 114 | --sidebar-primary-foreground: oklch(0.985 0 0); 115 | --sidebar-accent: oklch(0.274 0.006 286.033); 116 | --sidebar-accent-foreground: oklch(0.985 0 0); 117 | --sidebar-border: oklch(1 0 0 / 10%); 118 | --sidebar-ring: oklch(0.552 0.016 285.938); 119 | } 120 | 121 | @theme inline { 122 | --text-*: initial; 123 | --text-xs: 0.56rem; 124 | --text-sm: 0.75rem; 125 | --text-base: 1rem; 126 | --text-lg: 1.33rem; 127 | --text-xl: 1.78rem; 128 | --text-2xl: 2.37rem; 129 | --text-3xl: 3.16rem; 130 | --text-4xl: 4.21rem; 131 | --text-5xl: 5.61rem; 132 | --text-6xl: 7.48rem; 133 | --text-7xl: 9.97rem; 134 | 135 | --radius-sm: calc(var(--radius) - 4px); 136 | --radius-md: calc(var(--radius) - 2px); 137 | --radius-lg: var(--radius); 138 | --radius-xl: calc(var(--radius) + 4px); 139 | 140 | --color-background: var(--background); 141 | --color-foreground: var(--foreground); 142 | --color-card: var(--card); 143 | --color-card-foreground: var(--card-foreground); 144 | --color-popover: var(--popover); 145 | --color-popover-foreground: var(--popover-foreground); 146 | --color-primary: var(--primary); 147 | --color-primary-foreground: var(--primary-foreground); 148 | --color-secondary: var(--secondary); 149 | --color-secondary-foreground: var(--secondary-foreground); 150 | --color-muted: var(--muted); 151 | --color-muted-foreground: var(--muted-foreground); 152 | --color-accent: var(--accent); 153 | --color-accent-foreground: var(--accent-foreground); 154 | --color-destructive: var(--destructive); 155 | --color-border: var(--border); 156 | --color-input: var(--input); 157 | --color-ring: var(--ring); 158 | --color-chart-1: var(--chart-1); 159 | --color-chart-2: var(--chart-2); 160 | --color-chart-3: var(--chart-3); 161 | --color-chart-4: var(--chart-4); 162 | --color-chart-5: var(--chart-5); 163 | --color-sidebar: var(--sidebar); 164 | --color-sidebar-foreground: var(--sidebar-foreground); 165 | --color-sidebar-primary: var(--sidebar-primary); 166 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 167 | --color-sidebar-accent: var(--sidebar-accent); 168 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 169 | --color-sidebar-border: var(--sidebar-border); 170 | --color-sidebar-ring: var(--sidebar-ring); 171 | 172 | 173 | --font-*: initial; 174 | --font-sans: Lexend, sans-serif; 175 | 176 | 177 | } 178 | 179 | @layer base { 180 | * { 181 | @apply border-border outline-ring/50; 182 | } 183 | body { 184 | @apply bg-background text-foreground; 185 | } 186 | } -------------------------------------------------------------------------------- /src/routes/li/[room]/ListenerHeaderTitle.svelte: -------------------------------------------------------------------------------- 1 | 83 | 84 |
87 |
88 |

Room

89 |
90 | {#if isEditingTitle} 91 | 92 | { 99 | if (e.key === 'Enter') { 100 | if (roomTitle.length <= 0) return; 101 | updateRoomTitle(); 102 | toggleEditTitle(); 103 | } 104 | }} 105 | size={roomTitle.length > 5 ? roomTitle.length : 5} 106 | style={`font-size: ${Math.ceil(roomTitle.length / 50)}em`} 107 | /> 108 | 109 | 110 | 121 | 124 | 125 | {:else} 126 | 130 | {roomTitle} 131 | 132 | 133 | 136 | 137 | {/if} 138 |
139 |
140 | 141 | 151 | 162 | 167 | {pollingInterval} s 169 | 170 | {webhookUrl} 172 | 173 | {profanityEnabled ? 'Profanity Allowed' : 'Profanity Filter on'} 176 | 177 | {#key pollingInterval} 178 | {#if unpacking} 179 | 184 | Loading ... 185 | 186 | {:else} 187 | 192 | 193 | {/if} 194 | {/key} 195 | {@render children?.()} 196 |
197 | -------------------------------------------------------------------------------- /src/routes/li/[room]/+page.svelte: -------------------------------------------------------------------------------- 1 | 175 | 176 | 177 | 178 |
181 | {#if roomTitle && loadedPair && rid} 182 |
183 | 191 |
192 | 193 |
194 | {#if unlocked} 195 | {#each [...decryptedMessages].reverse() as msg (msg)} 196 | 197 | {/each} 198 | 199 | {#if !decryptedMessages.length} 200 | 203 | 204 |

No messages sent to your inbox yet

205 | 206 | Copy and share your link to get new messages ! 207 | 208 |
209 | {/if} 210 | {/if} 211 |
212 | {/if} 213 |
214 | -------------------------------------------------------------------------------- /src/routes/li/[room]/Message/Message.svelte: -------------------------------------------------------------------------------- 1 | 109 | 110 |
111 |
112 | 113 | 114 | 115 | 121 |   122 | 123 | 126 | {msg.r} 127 | 128 | 129 | 130 | {#if msg.image && msg.image.id && msg.image.blurhash} 131 | 132 | 133 | 134 | {/if} 135 | 136 | 137 | {msg.msg} 138 | 139 |
140 | {time} 141 |
142 | 150 |
151 |
152 | 153 | 154 | 155 | 156 | Download image 157 | 158 |
159 |
163 |
166 | 167 | 168 | 169 | 175 |   176 | 177 | 180 | {msg.r} 181 | 182 | 183 | 184 | {#if msg.image && msg.image.id && msg.image.blurhash} 185 | 186 | 187 | 188 | {/if} 189 | 190 | 191 | {msg.msg} 192 | 193 |
194 | {time} 195 |
196 |
197 |
198 |
199 |
200 |
201 | 202 |
203 | 218 | 226 |
227 |
228 |
229 |
230 | -------------------------------------------------------------------------------- /src/routes/b/[room]/+page.svelte: -------------------------------------------------------------------------------- 1 | 200 | 201 |
204 |
205 |
208 | Send to 209 | 210 | {#if roomTitle} 211 |

212 | [ {roomTitle} ] 213 |

214 | 215 | {params} 216 | 217 | {:else} 218 | {params} 219 | {/if} 220 |
221 |
222 |
223 | {message.length}/1000 224 |
225 | 226 |