├── .gitignore ├── LICENSE ├── README.md ├── bin ├── db.js ├── files.js ├── migrations │ ├── 01_init.sql │ └── 02_rls.sql └── script.js ├── example ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ └── supabase.ts ├── supabase │ └── config.json ├── tailwind.config.js └── tsconfig.json ├── notes.sql ├── package-lock.json ├── package.json ├── postcss.config.js ├── rollup.config.ts ├── src ├── api.ts ├── components │ ├── Auth.tsx │ ├── AuthModal.tsx │ ├── Avatar.tsx │ ├── Comment.tsx │ ├── CommentReaction.tsx │ ├── CommentReactions.tsx │ ├── Comments.tsx │ ├── CommentsProvider.tsx │ ├── Editor.module.css │ ├── Editor.tsx │ ├── Mentions.tsx │ ├── Reaction.tsx │ ├── ReactionSelector.tsx │ ├── ReplyManagerProvider.tsx │ ├── TimeAgo.tsx │ ├── User.tsx │ └── index.ts ├── global.css ├── hooks │ ├── index.ts │ ├── useAddComment.ts │ ├── useAddReaction.ts │ ├── useApi.ts │ ├── useAuthUtils.ts │ ├── useComment.ts │ ├── useCommentReactions.ts │ ├── useComments.ts │ ├── useCssPalette.ts │ ├── useDeleteComment.ts │ ├── useLatestRef.ts │ ├── useReaction.ts │ ├── useReactions.ts │ ├── useRemoveReaction.ts │ ├── useSearchUsers.ts │ ├── useUncontrolledState.ts │ ├── useUpdateComment.ts │ └── useUser.ts ├── index.ts ├── typings.d.ts └── utils.ts ├── tailwind.config.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # builds 5 | build 6 | dist 7 | 8 | # misc 9 | .cache 10 | .parcel-cache 11 | .DS_Store 12 | .env 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | *.log 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # temp directories created during testing 24 | my-test-library 25 | nala 26 | 27 | # editor 28 | .vscode 29 | .idea 30 | 31 | # example 32 | /example/node_modules 33 | /example/.next 34 | 35 | # Supabase 36 | **/supabase/.branches 37 | **/supabase/.temp 38 | **/supabase/.env -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 malerba118 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supabase Comments Extension 2 | 3 | Add a robust comment system to your react app in ~10 minutes! 4 | 5 | This library provides comments, replies, reactions, mentions, and authentication all out of the box. 6 | 7 | ## Demos 8 | 9 | 10 | - https://malerba118.github.io/supabase-comments-extension 11 | - https://codesandbox.io/s/supabase-comments-extension-demo-8hg9s?file=/src/App.tsx 12 | 13 | ## Getting Started 14 | 15 | 16 | First things first, this project is powered by [supabase](https://supabase.com/) so if you don't already have a supabase db, [head over there and make one](https://app.supabase.io/) (it's super simple and literally takes a few seconds) 17 | 18 | ### Installation 19 | 20 | Install this package and its peer dependencies 21 | 22 | ```bash 23 | npm install --save supabase-comments-extension @supabase/ui @supabase/supabase-js react-query 24 | ``` 25 | 26 | ### Running Migrations 27 | 28 | Once you've got yourself a supabase db, you'll need to add a few tables and other sql goodies to it with the following command 29 | 30 | ```bash 31 | npx supabase-comments-extension run-migrations 32 | ``` 33 | 34 | You can find your connection string on the supabase dashboard: https://app.supabase.io/project/PUT-YOUR-PROJECT-ID-HERE/settings/database 35 | 36 | It should look something like this: `postgresql://postgres:some-made-up-password@db.ddziybrgjepxqpsflsiv.supabase.co:5432/postgres` 37 | 38 | ### Usage With Auth 39 | 40 | Then in your app code you can add comments with the following 41 | 42 | ```jsx 43 | import { useState } from 'react'; 44 | import { createClient } from '@supabase/supabase-js'; 45 | import { 46 | Comments, 47 | AuthModal, 48 | CommentsProvider, 49 | } from 'supabase-comments-extension'; 50 | 51 | const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); 52 | 53 | const App = () => { 54 | const [modalVisible, setModalVisible] = useState(false); 55 | 56 | return ( 57 | setModalVisible(true)} 60 | > 61 | setModalVisible(false)} 64 | onClose={() => setModalVisible(false)} 65 | providers={['google', 'facebook']} 66 | /> 67 | 68 | 69 | ); 70 | }; 71 | ``` 72 | 73 | Note that [supabase supports social auth with dozens of providers out-of-the-box](https://supabase.com/docs/guides/auth#authentication) so you can sign in with Google, Facebook, Twitter, Github and many more. 74 | 75 | supabase-comments-extension exports two auth components, `Auth` and `AuthModal`. The `Auth` component is a small adaptation of [@supabase/ui's Auth component](https://ui.supabase.io/components/auth) and supports all of the same props. `AuthModal` also supports all of the same props as the `Auth` component along with [a few additional props](https://github.com/malerba118/supabase-comments-extension/edit/main/README.md#api). 76 | 77 | Lastly, if you want to write your own authentication ui, then know that the supbase client provides a method `supabase.auth.signIn` which can authenticate the supabase client without forcing any ui on you. 78 | 79 | ```tsx 80 | import { createClient } from '@supabase/supabase-js'; 81 | 82 | const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); 83 | 84 | // Social Auth 85 | const { user, error } = await supabase.auth.signIn({ 86 | provider: 'facebook', 87 | }) 88 | 89 | // Email/Password Auth 90 | const { user, error } = await supabase.auth.signIn({ 91 | email: 'example@email.com', 92 | password: 'example-password', 93 | }) 94 | ``` 95 | 96 | ### Usage Without Auth 97 | 98 | If you already have an app set up with supabase authentication, 99 | then you can skip the `AuthModal` and direct the user to your 100 | existing sign-in system. 101 | 102 | ```jsx 103 | import { useState } from 'react'; 104 | import { createClient } from '@supabase/supabase-js'; 105 | import { Comments, CommentsProvider } from 'supabase-comments-extension'; 106 | 107 | const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); 108 | 109 | const App = () => { 110 | return ( 111 | { 114 | window.location.href = '/sign-in'; 115 | }} 116 | > 117 | 118 | 119 | ); 120 | }; 121 | ``` 122 | 123 | ## Advanced Features 124 | 125 | supabase-comments-extension includes a handful of customization options to meet your app's needs 126 | 127 | ### Bring Your Own Reactions 128 | 129 | You can add your own reactions by adding rows to the `sce_reactions` table. 130 | 131 | Screen Shot 2022-02-01 at 4 31 55 PM 132 | 133 | It's easy to add rows via the supabase dashboard or if you prefer you can write some sql to insert new rows. 134 | 135 | ```sql 136 | insert into sce_reactions(type, label, url) values ('heart', 'Heart', 'https://emojis.slackmojis.com/emojis/images/1596061862/9845/meow_heart.png?1596061862'); 137 | insert into sce_reactions(type, label, url) values ('like', 'Like', 'https://emojis.slackmojis.com/emojis/images/1588108689/8789/fb-like.png?1588108689'); 138 | insert into sce_reactions(type, label, url) values ('party-blob', 'Party Blob', 'https://emojis.slackmojis.com/emojis/images/1547582922/5197/party_blob.gif?1547582922'); 139 | ``` 140 | 141 | ### Custom Reaction Rendering 142 | 143 | If you want to customize the way comment reactions are rendered then you're in luck! 144 | You can pass your own `CommentReactions` component to control exactly how reactions are rendered beneath each comment. 145 | 146 | ```tsx 147 | import { useState } from 'react'; 148 | import { createClient } from '@supabase/supabase-js'; 149 | import { Button } from '@supabase/ui'; 150 | import { 151 | Comments, 152 | CommentsProvider, 153 | CommentReactionsProps, 154 | } from 'supabase-comments-extension'; 155 | 156 | const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); 157 | 158 | const CustomCommentReactions: FC = ({ 159 | activeReactions, 160 | toggleReaction, 161 | }) => { 162 | return ( 163 | 166 | ); 167 | }; 168 | 169 | const App = () => { 170 | return ( 171 | 177 | 178 | 179 | ); 180 | }; 181 | ``` 182 | 183 | The above code will render the following ui 184 | 185 | Screen Shot 2022-02-01 at 8 34 33 PM 186 | 187 | ### Handling Mentions 188 | 189 | This library includes support for mentions, however mentions are fairly useless without a way to notify the users who are mentioned. You can listen to mentions via postgres triggers and perform some action in response such as insert into a notifications table or send an http request to a custom endpoint. 190 | 191 | ```sql 192 | CREATE OR REPLACE FUNCTION notify_mentioned_users() 193 | RETURNS trigger AS 194 | $$ 195 | DECLARE 196 | mentioned_user_id uuid; 197 | BEGIN 198 | FOREACH mentioned_user_id IN ARRAY NEW.mentioned_user_ids LOOP 199 | INSERT INTO your_notifications_table (actor, action, receiver) VALUES(NEW.user_id, 'mention', mentioned_user_id); 200 | END LOOP; 201 | RETURN NEW; 202 | END; 203 | $$ 204 | LANGUAGE 'plpgsql'; 205 | 206 | CREATE TRIGGER comment_insert_trigger 207 | AFTER INSERT 208 | ON sce_comments 209 | FOR EACH ROW 210 | EXECUTE PROCEDURE notify_mentioned_users(); 211 | ``` 212 | 213 | If you don't care about mentions, then you can disable them via the `CommentsProvider` 214 | 215 | ```tsx 216 | 217 | 218 | 219 | ``` 220 | 221 | ## API 222 | 223 | Here's the prop options for primary components you'll be working with 224 | 225 | ```tsx 226 | interface CommentsProviderProps { 227 | queryClient?: QueryClient; 228 | supabaseClient: SupabaseClient; 229 | onAuthRequested?: () => void; 230 | onUserClick?: (user: DisplayUser) => void; 231 | mode?: 'light' | 'dark'; 232 | accentColor?: string; 233 | onError?: (error: ApiError, query: Query) => void; 234 | components?: { 235 | CommentReactions?: ComponentType<{ 236 | activeReactions: Set; 237 | reactionsMetadata: api.CommentReactionMetadata[]; 238 | toggleReaction: (reactionType: string) => void; 239 | }>; 240 | }; 241 | enableMentions?: boolean; 242 | } 243 | 244 | interface CommentsProps { 245 | topic: string; 246 | } 247 | 248 | interface AuthModalProps extends AuthProps { 249 | visible: boolean; 250 | onClose?: () => void; 251 | onAuthenticate?: (session: Session) => void; 252 | title?: string; 253 | description?: string; 254 | } 255 | 256 | // This comes from @supabase/ui (https://ui.supabase.io/components/auth) 257 | // supabase-comments-extension provides an adapted version of supabase ui's 258 | // Auth component with support for display names/avatars 259 | interface AuthProps { 260 | supabaseClient: SupabaseClient 261 | className?: string 262 | children?: React.ReactNode 263 | style?: React.CSSProperties 264 | socialLayout?: 'horizontal' | 'vertical' 265 | socialColors?: boolean 266 | socialButtonSize?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge' 267 | providers?: Provider[] 268 | verticalSocialLayout?: any 269 | view?: ViewType 270 | redirectTo?: RedirectTo 271 | onlyThirdPartyProviders?: boolean 272 | magicLink?: boolean 273 | } 274 | ``` 275 | 300 | -------------------------------------------------------------------------------- /bin/db.js: -------------------------------------------------------------------------------- 1 | const { Client } = require('pg'); 2 | 3 | const INIT_MIGRATIONS_TABLE_SQL = ` 4 | create table if not exists "public"."sce_migrations" ( 5 | "migration" text primary key, 6 | "created_at" timestamp with time zone default now() 7 | ); 8 | 9 | alter table "public"."sce_migrations" enable row level security; 10 | `; 11 | 12 | const MIGRATION_EXISTS_SQL = ` 13 | SELECT EXISTS (SELECT * FROM sce_migrations where migration = $1); 14 | `; 15 | 16 | const INSERT_MIGRATION_SQL = ` 17 | INSERT INTO sce_migrations(migration) VALUES ($1); 18 | `; 19 | 20 | const DbClient = async (connectionString) => { 21 | const client = new Client({ 22 | connectionString, 23 | }); 24 | await client.connect(); 25 | const initMigrationsTable = async () => { 26 | const result = await client.query(INIT_MIGRATIONS_TABLE_SQL); 27 | return result.rows; 28 | }; 29 | const hasRunMigration = async (migrationName) => { 30 | const result = await client.query(MIGRATION_EXISTS_SQL, [migrationName]); 31 | return result.rows[0]?.exists; 32 | }; 33 | const runMigration = async (migrationName, migrationSql) => { 34 | await client.query(migrationSql); 35 | await client.query(INSERT_MIGRATION_SQL, [migrationName]); 36 | }; 37 | const reloadSchema = async () => { 38 | await client.query(`NOTIFY pgrst, 'reload schema';`); 39 | }; 40 | return { 41 | initMigrationsTable, 42 | hasRunMigration, 43 | runMigration, 44 | reloadSchema, 45 | }; 46 | }; 47 | 48 | module.exports = { 49 | DbClient, 50 | }; 51 | -------------------------------------------------------------------------------- /bin/files.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs/promises'); 2 | const path = require('path'); 3 | 4 | const getMigrationNames = async () => { 5 | const migrationNames = await fs.readdir(path.join(__dirname, './migrations')); 6 | return migrationNames.sort(); 7 | }; 8 | 9 | const getMigrationSql = async (migrationName) => { 10 | return fs.readFile(path.join(__dirname, './migrations', migrationName), { 11 | encoding: 'utf-8', 12 | }); 13 | }; 14 | 15 | module.exports = { 16 | getMigrationNames, 17 | getMigrationSql, 18 | }; 19 | -------------------------------------------------------------------------------- /bin/migrations/01_init.sql: -------------------------------------------------------------------------------- 1 | create table "public"."sce_comment_reactions" ( 2 | "id" uuid not null default uuid_generate_v4(), 3 | "created_at" timestamp with time zone default now(), 4 | "comment_id" uuid not null, 5 | "user_id" uuid not null, 6 | "reaction_type" character varying not null 7 | ); 8 | 9 | 10 | create table "public"."sce_comments" ( 11 | "id" uuid not null default uuid_generate_v4(), 12 | "created_at" timestamp with time zone default now(), 13 | "topic" character varying not null, 14 | "comment" character varying not null, 15 | "user_id" uuid not null, 16 | "parent_id" uuid, 17 | "mentioned_user_ids" uuid[] not null default '{}'::uuid[] 18 | ); 19 | 20 | 21 | create table "public"."sce_reactions" ( 22 | "type" character varying not null, 23 | "created_at" timestamp with time zone default now(), 24 | "label" character varying not null, 25 | "url" character varying not null, 26 | "metadata" jsonb 27 | ); 28 | 29 | 30 | CREATE UNIQUE INDEX sce_comment_reactions_pkey ON public.sce_comment_reactions USING btree (id); 31 | 32 | CREATE UNIQUE INDEX sce_comment_reactions_user_id_comment_id_reaction_type_key ON public.sce_comment_reactions USING btree (user_id, comment_id, reaction_type); 33 | 34 | CREATE UNIQUE INDEX sce_comments_pkey ON public.sce_comments USING btree (id); 35 | 36 | CREATE UNIQUE INDEX sce_reactions_pkey ON public.sce_reactions USING btree (type); 37 | 38 | alter table "public"."sce_comment_reactions" add constraint "sce_comment_reactions_pkey" PRIMARY KEY using index "sce_comment_reactions_pkey"; 39 | 40 | alter table "public"."sce_comments" add constraint "sce_comments_pkey" PRIMARY KEY using index "sce_comments_pkey"; 41 | 42 | alter table "public"."sce_reactions" add constraint "sce_reactions_pkey" PRIMARY KEY using index "sce_reactions_pkey"; 43 | 44 | alter table "public"."sce_comment_reactions" add constraint "sce_comment_reactions_comment_id_fkey" FOREIGN KEY (comment_id) REFERENCES sce_comments(id) ON DELETE CASCADE; 45 | 46 | alter table "public"."sce_comment_reactions" add constraint "sce_comment_reactions_reaction_type_fkey" FOREIGN KEY (reaction_type) REFERENCES sce_reactions(type); 47 | 48 | alter table "public"."sce_comment_reactions" add constraint "sce_comment_reactions_user_id_comment_id_reaction_type_key" UNIQUE using index "sce_comment_reactions_user_id_comment_id_reaction_type_key"; 49 | 50 | alter table "public"."sce_comment_reactions" add constraint "sce_comment_reactions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; 51 | 52 | alter table "public"."sce_comments" add constraint "sce_comments_parent_id_fkey" FOREIGN KEY (parent_id) REFERENCES sce_comments(id) ON DELETE CASCADE; 53 | 54 | alter table "public"."sce_comments" add constraint "sce_comments_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; 55 | 56 | create or replace view "public"."sce_comment_reactions_metadata" as SELECT sce_comment_reactions.comment_id, 57 | sce_comment_reactions.reaction_type, 58 | count(*) AS reaction_count, 59 | bool_or((sce_comment_reactions.user_id = auth.uid())) AS active_for_user 60 | FROM sce_comment_reactions 61 | GROUP BY sce_comment_reactions.comment_id, sce_comment_reactions.reaction_type 62 | ORDER BY sce_comment_reactions.reaction_type; 63 | 64 | 65 | create or replace view "public"."sce_comments_with_metadata" as SELECT sce_comments.id, 66 | sce_comments.created_at, 67 | sce_comments.topic, 68 | sce_comments.comment, 69 | sce_comments.user_id, 70 | sce_comments.parent_id, 71 | sce_comments.mentioned_user_ids, 72 | ( SELECT count(*) AS count 73 | FROM sce_comments c 74 | WHERE (c.parent_id = sce_comments.id)) AS replies_count 75 | FROM sce_comments; 76 | 77 | 78 | create or replace view "public"."sce_display_users" as SELECT users.id, 79 | COALESCE((users.raw_user_meta_data ->> 'name'::text), (users.raw_user_meta_data ->> 'full_name'::text), (users.raw_user_meta_data ->> 'user_name'::text)) AS name, 80 | COALESCE((users.raw_user_meta_data ->> 'avatar_url'::text), (users.raw_user_meta_data ->> 'avatar'::text)) AS avatar 81 | FROM auth.users; 82 | 83 | -- seed some basic reactions 84 | insert into sce_reactions(type, label, url) values ('heart', 'Heart', 'https://emojis.slackmojis.com/emojis/images/1596061862/9845/meow_heart.png?1596061862'); 85 | insert into sce_reactions(type, label, url) values ('like', 'Like', 'https://emojis.slackmojis.com/emojis/images/1588108689/8789/fb-like.png?1588108689'); 86 | insert into sce_reactions(type, label, url) values ('party-blob', 'Party Blob', 'https://emojis.slackmojis.com/emojis/images/1547582922/5197/party_blob.gif?1547582922'); 87 | 88 | -------------------------------------------------------------------------------- /bin/migrations/02_rls.sql: -------------------------------------------------------------------------------- 1 | alter table "public"."sce_comment_reactions" enable row level security; 2 | 3 | alter table "public"."sce_comments" enable row level security; 4 | 5 | alter table "public"."sce_reactions" enable row level security; 6 | 7 | create policy "Enable access to all users" 8 | on "public"."sce_comment_reactions" 9 | as permissive 10 | for select 11 | to public 12 | using (true); 13 | 14 | 15 | create policy "Enable delete for users based on user_id" 16 | on "public"."sce_comment_reactions" 17 | as permissive 18 | for delete 19 | to public 20 | using ((auth.uid() = user_id)); 21 | 22 | 23 | create policy "Enable insert for authenticated users only" 24 | on "public"."sce_comment_reactions" 25 | as permissive 26 | for insert 27 | to public 28 | with check ((auth.role() = 'authenticated'::text) AND (user_id = auth.uid())); 29 | 30 | 31 | create policy "Enable update for users based on user_id" 32 | on "public"."sce_comment_reactions" 33 | as permissive 34 | for update 35 | to public 36 | using ((auth.uid() = user_id)) 37 | with check (auth.uid() = user_id); 38 | 39 | 40 | create policy "Enable access to all users" 41 | on "public"."sce_comments" 42 | as permissive 43 | for select 44 | to public 45 | using (true); 46 | 47 | 48 | create policy "Enable delete for users based on user_id" 49 | on "public"."sce_comments" 50 | as permissive 51 | for delete 52 | to public 53 | using ((auth.uid() = user_id)); 54 | 55 | 56 | create policy "Enable insert for authenticated users only" 57 | on "public"."sce_comments" 58 | as permissive 59 | for insert 60 | to public 61 | with check ((auth.role() = 'authenticated'::text) AND (user_id = auth.uid())); 62 | 63 | 64 | create policy "Enable update for users based on id" 65 | on "public"."sce_comments" 66 | as permissive 67 | for update 68 | to public 69 | using ((auth.uid() = user_id)) 70 | with check (auth.uid() = user_id); 71 | 72 | 73 | create policy "Enable access to all users" 74 | on "public"."sce_reactions" 75 | as permissive 76 | for select 77 | to public 78 | using (true); 79 | -------------------------------------------------------------------------------- /bin/script.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { Command } = require('commander'); 3 | const { DbClient } = require('./db'); 4 | const files = require('./files'); 5 | const packageJson = require('../package.json'); 6 | 7 | const program = new Command(); 8 | 9 | program.version(packageJson.version); 10 | 11 | program 12 | .command('run-migrations') 13 | .argument('') 14 | .action(async (connectionUrl) => { 15 | const db = await DbClient(connectionUrl); 16 | 17 | await db.initMigrationsTable(); 18 | 19 | console.log('\nRUNNING MIGRATIONS\n'); 20 | 21 | const migrationNames = await files.getMigrationNames(); 22 | 23 | let successful = true; 24 | 25 | for (migrationName of migrationNames) { 26 | try { 27 | const hasRun = await db.hasRunMigration(migrationName); 28 | if (hasRun) { 29 | console.log(`SKIPPING MIGRATION: ${migrationName}`); 30 | } else { 31 | console.log(`RUNNING MIGRATION: ${migrationName}`); 32 | const migrationSql = await files.getMigrationSql(migrationName); 33 | await db.runMigration(migrationName, migrationSql); 34 | } 35 | } catch (err) { 36 | console.error(`\nERROR RUNNING MIGRATION: ${migrationName}\n`); 37 | console.error(err.message); 38 | console.log('\nSKIPPING REMAINING MIGRATIONS\n'); 39 | successful = false; 40 | break; 41 | } 42 | } 43 | await db.reloadSchema(); 44 | if (successful) { 45 | console.log('\nMIGRATIONS APPLIED SUCCESSFULLY\n'); 46 | } 47 | process.exit(0); 48 | }); 49 | 50 | program.parseAsync(process.argv); 51 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://malerba118.github.io/supabase-comments-extension", 6 | "dependencies": { 7 | "@chakra-ui/media-query": "^1.2.3", 8 | "@chakra-ui/react": "^1.8.1", 9 | "@emotion/react": "^11.7.1", 10 | "@emotion/styled": "^11.6.0", 11 | "@supabase/supabase-js": "^1.29.2", 12 | "@supabase/ui": "file:../node_modules/@supabase/ui", 13 | "@testing-library/jest-dom": "^5.16.1", 14 | "@testing-library/react": "^12.1.2", 15 | "@testing-library/user-event": "^13.5.0", 16 | "@types/jest": "^27.4.0", 17 | "@types/node": "^16.11.20", 18 | "@types/react": "^17.0.38", 19 | "@types/react-dom": "^17.0.11", 20 | "ace-builds": "^1.4.14", 21 | "framer-motion": "^5.6.0", 22 | "react": "file:../node_modules/react", 23 | "react-ace": "^9.5.0", 24 | "react-dom": "file:../node_modules/react-dom", 25 | "react-query": "^3.34.8", 26 | "react-scripts": "5.0.0", 27 | "supabase-comments-extension": "file:../", 28 | "typescript": "^4.5.4", 29 | "web-vitals": "^2.1.3" 30 | }, 31 | "scripts": { 32 | "start": "react-scripts start", 33 | "build": "react-scripts build", 34 | "test": "react-scripts test", 35 | "eject": "react-scripts eject", 36 | "predeploy": "npm run build", 37 | "deploy": "gh-pages -d build" 38 | }, 39 | "eslintConfig": { 40 | "extends": [ 41 | "react-app", 42 | "react-app/jest" 43 | ] 44 | }, 45 | "browserslist": { 46 | "production": [ 47 | ">0.2%", 48 | "not dead", 49 | "not op_mini all" 50 | ], 51 | "development": [ 52 | "last 1 chrome version", 53 | "last 1 firefox version", 54 | "last 1 safari version" 55 | ] 56 | }, 57 | "devDependencies": { 58 | "autoprefixer": "^10.4.2", 59 | "gh-pages": "^3.2.3", 60 | "postcss": "^8.4.5", 61 | "tailwindcss": "^3.0.15" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malerba118/supabase-comments-extension/5680c36877a69532ddab6cd8d0875a9f9500f0dd/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | supabase-comments-extension 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malerba118/supabase-comments-extension/5680c36877a69532ddab6cd8d0875a9f9500f0dd/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malerba118/supabase-comments-extension/5680c36877a69532ddab6cd8d0875a9f9500f0dd/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, FC, useLayoutEffect } from 'react'; 2 | import { Auth, Button, Menu } from '@supabase/ui'; 3 | import { 4 | Comments, 5 | CommentsProvider, 6 | AuthModal, 7 | CommentReactionsProps, 8 | } from 'supabase-comments-extension'; 9 | import supabase from './supabase'; 10 | import AceEditor from 'react-ace'; 11 | import { useBreakpointValue } from '@chakra-ui/media-query'; 12 | 13 | import 'ace-builds/src-noconflict/theme-twilight'; 14 | import 'ace-builds/src-noconflict/mode-javascript'; 15 | import 'ace-builds/src-noconflict/mode-jsx'; 16 | import 'ace-builds/src-noconflict/mode-tsx'; 17 | 18 | interface Example { 19 | key: string; 20 | label: string; 21 | Component: FC<{ 22 | topic: string; 23 | }>; 24 | code: string; 25 | } 26 | 27 | const CustomCommentReactions: FC = ({ 28 | activeReactions, 29 | toggleReaction, 30 | }) => { 31 | return ( 32 | 35 | ); 36 | }; 37 | 38 | const examples: Record = { 39 | darkMode: { 40 | key: 'darkMode', 41 | label: 'Dark Mode', 42 | Component: () => { 43 | useLayoutEffect(() => { 44 | const prevColor = document.body.style.backgroundColor; 45 | document.body.style.backgroundColor = '#181818'; 46 | return () => { 47 | document.body.style.backgroundColor = prevColor; 48 | }; 49 | }, []); 50 | 51 | return ( 52 | { 55 | window.alert('Auth Requested'); 56 | }} 57 | onUserClick={(user) => { 58 | window.alert(user.name); 59 | }} 60 | mode="dark" 61 | accentColor="#924b9b" 62 | > 63 | 64 | 65 | ); 66 | }, 67 | code: `const App = () => { 68 | return ( 69 | { 72 | window.alert('Auth Requested'); 73 | }} 74 | onUserClick={(user) => { 75 | window.alert(user.name); 76 | }} 77 | mode="dark" 78 | accentColor="#924b9b" 79 | > 80 | 81 | 82 | ); 83 | };`, 84 | }, 85 | lightMode: { 86 | key: 'lightMode', 87 | label: 'Light Mode', 88 | Component: () => { 89 | return ( 90 | { 93 | window.alert('Auth Requested'); 94 | }} 95 | onUserClick={(user) => { 96 | window.alert(user.name); 97 | }} 98 | onError={console.log} 99 | mode="light" 100 | > 101 | 102 | 103 | ); 104 | }, 105 | code: `const App = () => { 106 | return ( 107 | { 110 | window.alert('Auth Requested'); 111 | }} 112 | onUserClick={(user) => { 113 | window.alert(user.name); 114 | }} 115 | mode="light" 116 | > 117 | 118 | 119 | ); 120 | };`, 121 | }, 122 | withAuth: { 123 | key: 'withAuth', 124 | label: 'With AuthModal', 125 | Component: () => { 126 | const [modalVisible, setModalVisible] = useState(false); 127 | 128 | return ( 129 | { 132 | setModalVisible(true); 133 | }} 134 | onUserClick={(user) => { 135 | window.alert(user.name); 136 | }} 137 | accentColor="#904a99" 138 | > 139 | { 142 | setModalVisible(false); 143 | }} 144 | onClose={() => { 145 | setModalVisible(false); 146 | }} 147 | providers={['twitter']} 148 | redirectTo={ 149 | process.env.NODE_ENV === 'development' 150 | ? 'http://localhost:3000' 151 | : 'https://malerba118.github.io/supabase-comments-extension' 152 | } 153 | /> 154 | 155 | 156 | ); 157 | }, 158 | code: `const App = () => { 159 | const [modalVisible, setModalVisible] = useState(false); 160 | 161 | return ( 162 | { 165 | setModalVisible(true); 166 | }} 167 | onUserClick={(user) => { 168 | window.alert(user.name); 169 | }} 170 | accentColor="#904a99" 171 | > 172 | { 175 | setModalVisible(false); 176 | }} 177 | onClose={() => { 178 | setModalVisible(false); 179 | }} 180 | /> 181 | 182 | 183 | ); 184 | };`, 185 | }, 186 | customReactions: { 187 | key: 'customReactions', 188 | label: 'Custom Reactions', 189 | Component: () => { 190 | return ( 191 | { 194 | window.alert('Auth Requested'); 195 | }} 196 | onUserClick={(user) => { 197 | window.alert(user.name); 198 | }} 199 | components={{ 200 | CommentReactions: CustomCommentReactions, 201 | }} 202 | > 203 | 204 | 205 | ); 206 | }, 207 | code: `const CustomCommentReactions: FC = ({ 208 | activeReactions, 209 | toggleReaction 210 | }) => { 211 | return ( 212 | 215 | ); 216 | }; 217 | 218 | const App = () => { 219 | return ( 220 | { 223 | window.alert('Auth Requested'); 224 | }} 225 | onUserClick={(user) => { 226 | window.alert(user.name); 227 | }} 228 | components={{ 229 | CommentReactions: CustomCommentReactions, 230 | }} 231 | > 232 | 233 | 234 | ); 235 | };`, 236 | }, 237 | withoutMentions: { 238 | key: 'withoutMentions', 239 | label: 'Without Mentions', 240 | Component: () => { 241 | return ( 242 | { 245 | window.alert('Auth Requested'); 246 | }} 247 | onUserClick={(user) => { 248 | window.alert(user.name); 249 | }} 250 | enableMentions={false} 251 | > 252 | 253 | 254 | ); 255 | }, 256 | code: `const App = () => { 257 | return ( 258 | { 261 | window.alert('Auth Requested'); 262 | }} 263 | onUserClick={(user) => { 264 | window.alert(user.name); 265 | }} 266 | enableMentions={false} 267 | > 268 | 269 | 270 | ); 271 | };`, 272 | }, 273 | }; 274 | 275 | const Sidenav = ({ activeExample, onExampleChange }: any) => { 276 | return ( 277 | 278 | 279 |

280 | supabase-comments-extension 281 |

282 |
283 | {Object.values(examples).map((ex) => ( 284 | { 286 | onExampleChange(ex.key); 287 | }} 288 | key={ex.key} 289 | active={ex.key === activeExample} 290 | > 291 | {ex.label} 292 | 293 | ))} 294 |
295 | ); 296 | }; 297 | 298 | const App = () => { 299 | const auth = Auth.useUser(); 300 | const [modalVisible, setModalVisible] = useState(false); 301 | const [activeExample, setActiveExample] = useState('darkMode'); 302 | const editorWidth = useBreakpointValue({ base: '24rem', md: '600px' }); 303 | 304 | const Component = examples[activeExample].Component; 305 | const code = examples[activeExample].code; 306 | 307 | return ( 308 |
309 |
310 | 314 |
315 |
316 | 345 |
346 | 358 |
359 | 360 |
361 |
362 |
363 |
364 | ); 365 | }; 366 | 367 | export default App; 368 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 16 | monospace; 17 | } 18 | 19 | body { 20 | /* background: #111; */ 21 | } 22 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | import { Auth } from '@supabase/ui'; 7 | import supabase from './supabase'; 8 | import { ChakraProvider } from '@chakra-ui/react'; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | , 18 | document.getElementById('root') 19 | ); 20 | 21 | // If you want to start measuring performance in your app, pass a function 22 | // to log results (for example: reportWebVitals(console.log)) 23 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 24 | reportWebVitals(); 25 | -------------------------------------------------------------------------------- /example/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /example/src/supabase.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | 3 | // const SUPABASE_URL = 'http://localhost:54321'; 4 | // const SUPABASE_ANON_KEY = 5 | // 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiJ9.ZopqoUt20nEV9cklpv9e3yw3PVyZLmKs5qLD6nGL1SI'; 6 | 7 | const SUPABASE_URL = 'https://cdayanxkvcxugtlatoaa.supabase.co'; 8 | const SUPABASE_ANON_KEY = 9 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYzOTM0MjE1MiwiZXhwIjoxOTU0OTE4MTUyfQ.pXXLHtODLQLrwYQcIXxUFuv-UQVnZNgIKjMjLlZI-EA'; 10 | 11 | const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { 12 | autoRefreshToken: true, 13 | }); 14 | 15 | export default supabase; 16 | -------------------------------------------------------------------------------- /example/supabase/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectId": "supabase-comment-system", 3 | "ports": { 4 | "api": 54321, 5 | "db": 54322, 6 | "studio": 54323 7 | }, 8 | "dbVersion": "140001" 9 | } 10 | -------------------------------------------------------------------------------- /example/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /notes.sql: -------------------------------------------------------------------------------- 1 | 2 | -- get replies count 3 | drop view sce_comments_with_metadata; 4 | create view sce_comments_with_metadata as select *, (select count(*) from sce_comments as c where c.parent_id = sce_comments.id) as replies_count from sce_comments; 5 | 6 | -- unique constraint on comment_reactions 7 | ALTER TABLE sce_comment_reactions ADD UNIQUE (user_id, comment_id, reaction_type); 8 | 9 | -- aggregate metadata for comment reactions 10 | create or replace view sce_comment_reactions_metadata as SELECT comment_id, reaction_type, COUNT(*) as reaction_count, BOOL_OR(user_id = auth.uid()) as active_for_user FROM sce_comment_reactions GROUP BY (comment_id, reaction_type) ORDER BY reaction_type; 11 | 12 | -- display_users view for user avatars 13 | create or replace view sce_display_users as select 14 | id, 15 | coalesce(raw_user_meta_data ->> 'name', raw_user_meta_data ->> 'full_name', raw_user_meta_data ->> 'user_name') as name, 16 | coalesce(raw_user_meta_data ->> 'avatar_url', raw_user_meta_data ->> 'avatar') as avatar 17 | from auth.users; 18 | 19 | -- RELOADING SCHEMA CACHE 20 | -- Create an event trigger function 21 | CREATE OR REPLACE FUNCTION public.pgrst_watch() RETURNS event_trigger 22 | LANGUAGE plpgsql 23 | AS $$ 24 | BEGIN 25 | NOTIFY pgrst, 'reload schema'; 26 | END; 27 | $$; 28 | 29 | -- This event trigger will fire after every ddl_command_end event 30 | CREATE EVENT TRIGGER pgrst_watch 31 | ON ddl_command_end 32 | EXECUTE PROCEDURE public.pgrst_watch(); 33 | 34 | 35 | -- cascade deletes for comments to delete replies when parent deleted 36 | alter table public.sce_comments 37 | drop constraint sce_comments_parent_id_fkey; 38 | alter table public.sce_comments 39 | add constraint sce_comments_parent_id_fkey 40 | foreign key (parent_id) 41 | references public.sce_comments (id) 42 | on delete cascade; 43 | 44 | -- add some basic reactions 45 | insert into sce_reactions(type, label, url) values ('heart', 'Heart', 'https://emojis.slackmojis.com/emojis/images/1596061862/9845/meow_heart.png?1596061862'); 46 | insert into sce_reactions(type, label, url) values ('like', 'Like', 'https://emojis.slackmojis.com/emojis/images/1588108689/8789/fb-like.png?1588108689'); 47 | insert into sce_reactions(type, label, url) values ('party-blob', 'Party Blob', 'https://emojis.slackmojis.com/emojis/images/1547582922/5197/party_blob.gif?1547582922'); 48 | 49 | -- GENERATED FROM MIGRA 50 | create table "public"."comment_reactions" ( 51 | "id" uuid not null default uuid_generate_v4(), 52 | "created_at" timestamp with time zone default now(), 53 | "comment_id" uuid not null, 54 | "user_id" uuid not null, 55 | "reaction_type" character varying not null 56 | ); 57 | 58 | 59 | create table "public"."comments" ( 60 | "id" uuid not null default uuid_generate_v4(), 61 | "created_at" timestamp with time zone default now(), 62 | "topic" character varying not null, 63 | "comment" character varying not null, 64 | "user_id" uuid not null, 65 | "parent_id" uuid, 66 | "mentioned_user_ids" uuid[] not null default '{}'::uuid[] 67 | ); 68 | 69 | 70 | create table "public"."profiles" ( 71 | "id" uuid not null, 72 | "created_at" timestamp with time zone default now(), 73 | "name" character varying, 74 | "avatar" character varying 75 | ); 76 | 77 | 78 | create table "public"."reactions" ( 79 | "type" character varying not null, 80 | "created_at" timestamp with time zone default now(), 81 | "metadata" jsonb 82 | ); 83 | 84 | 85 | CREATE UNIQUE INDEX comment_reactions_pkey ON public.comment_reactions USING btree (id); 86 | 87 | CREATE UNIQUE INDEX comment_reactions_user_id_comment_id_reaction_type_key ON public.comment_reactions USING btree (user_id, comment_id, reaction_type); 88 | 89 | CREATE UNIQUE INDEX comments_pkey ON public.comments USING btree (id); 90 | 91 | CREATE UNIQUE INDEX profiles_pkey ON public.profiles USING btree (id); 92 | 93 | CREATE UNIQUE INDEX reactions_pkey ON public.reactions USING btree (type); 94 | 95 | alter table "public"."comment_reactions" add constraint "comment_reactions_pkey" PRIMARY KEY using index "comment_reactions_pkey"; 96 | 97 | alter table "public"."comments" add constraint "comments_pkey" PRIMARY KEY using index "comments_pkey"; 98 | 99 | alter table "public"."profiles" add constraint "profiles_pkey" PRIMARY KEY using index "profiles_pkey"; 100 | 101 | alter table "public"."reactions" add constraint "reactions_pkey" PRIMARY KEY using index "reactions_pkey"; 102 | 103 | alter table "public"."comment_reactions" add constraint "comment_reactions_comment_id_fkey" FOREIGN KEY (comment_id) REFERENCES comments(id); 104 | 105 | alter table "public"."comment_reactions" add constraint "comment_reactions_reaction_type_fkey" FOREIGN KEY (reaction_type) REFERENCES reactions(type); 106 | 107 | alter table "public"."comment_reactions" add constraint "comment_reactions_user_id_comment_id_reaction_type_key" UNIQUE using index "comment_reactions_user_id_comment_id_reaction_type_key"; 108 | 109 | alter table "public"."comment_reactions" add constraint "comment_reactions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id); 110 | 111 | alter table "public"."comments" add constraint "comments_parent_id_fkey" FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE; 112 | 113 | alter table "public"."comments" add constraint "comments_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id); 114 | 115 | alter table "public"."profiles" add constraint "profiles_id_fkey" FOREIGN KEY (id) REFERENCES auth.users(id); 116 | 117 | set check_function_bodies = off; 118 | 119 | create or replace view "public"."comment_reactions_metadata" as SELECT comment_reactions.comment_id, 120 | comment_reactions.reaction_type, 121 | count(*) AS reaction_count, 122 | bool_or((comment_reactions.user_id = auth.uid())) AS active_for_user 123 | FROM comment_reactions 124 | GROUP BY comment_reactions.comment_id, comment_reactions.reaction_type; 125 | 126 | 127 | create or replace view "public"."comment_reactions_metadata_two" as SELECT comment_reactions.comment_id, 128 | comment_reactions.reaction_type, 129 | count(*) AS reaction_count 130 | FROM comment_reactions 131 | GROUP BY comment_reactions.comment_id, comment_reactions.reaction_type; 132 | 133 | 134 | create or replace view "public"."comments_with_metadata" as SELECT comments.id, 135 | comments.created_at, 136 | comments.topic, 137 | comments.comment, 138 | comments.user_id, 139 | comments.parent_id, 140 | comments.mentioned_user_ids, 141 | ( SELECT count(*) AS count 142 | FROM comments c 143 | WHERE (c.parent_id = comments.id)) AS replies_count 144 | FROM comments; 145 | 146 | 147 | create or replace view "public"."display_users" as SELECT users.id, 148 | COALESCE((users.raw_user_meta_data ->> 'name'::text), (users.raw_user_meta_data ->> 'full_name'::text), (users.raw_user_meta_data ->> 'user_name'::text)) AS name, 149 | COALESCE((users.raw_user_meta_data ->> 'avatar_url'::text), (users.raw_user_meta_data ->> 'avatar'::text)) AS avatar 150 | FROM auth.users; 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | -- MIGRA DIFF TWO 163 | create table "public"."comment_reactions" ( 164 | "id" uuid not null default uuid_generate_v4(), 165 | "created_at" timestamp with time zone default now(), 166 | "comment_id" uuid not null, 167 | "user_id" uuid not null, 168 | "reaction_type" character varying not null 169 | ); 170 | 171 | 172 | create table "public"."comments" ( 173 | "id" uuid not null default uuid_generate_v4(), 174 | "created_at" timestamp with time zone default now(), 175 | "topic" character varying not null, 176 | "comment" character varying not null, 177 | "user_id" uuid not null, 178 | "parent_id" uuid, 179 | "mentioned_user_ids" uuid[] not null default '{}'::uuid[] 180 | ); 181 | 182 | 183 | create table "public"."reactions" ( 184 | "type" character varying not null, 185 | "created_at" timestamp with time zone default now(), 186 | "metadata" jsonb, 187 | "label" character varying not null, 188 | "url" character varying not null 189 | ); 190 | 191 | 192 | CREATE UNIQUE INDEX comment_reactions_pkey ON public.comment_reactions USING btree (id); 193 | 194 | CREATE UNIQUE INDEX comment_reactions_user_id_comment_id_reaction_type_key ON public.comment_reactions USING btree (user_id, comment_id, reaction_type); 195 | 196 | CREATE UNIQUE INDEX comments_pkey ON public.comments USING btree (id); 197 | 198 | CREATE UNIQUE INDEX reactions_pkey ON public.reactions USING btree (type); 199 | 200 | alter table "public"."comment_reactions" add constraint "comment_reactions_pkey" PRIMARY KEY using index "comment_reactions_pkey"; 201 | 202 | alter table "public"."comments" add constraint "comments_pkey" PRIMARY KEY using index "comments_pkey"; 203 | 204 | alter table "public"."reactions" add constraint "reactions_pkey" PRIMARY KEY using index "reactions_pkey"; 205 | 206 | alter table "public"."comment_reactions" add constraint "comment_reactions_comment_id_fkey" FOREIGN KEY (comment_id) REFERENCES comments(id); 207 | 208 | alter table "public"."comment_reactions" add constraint "comment_reactions_reaction_type_fkey" FOREIGN KEY (reaction_type) REFERENCES reactions(type); 209 | 210 | alter table "public"."comment_reactions" add constraint "comment_reactions_user_id_comment_id_reaction_type_key" UNIQUE using index "comment_reactions_user_id_comment_id_reaction_type_key"; 211 | 212 | alter table "public"."comment_reactions" add constraint "comment_reactions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id); 213 | 214 | alter table "public"."comments" add constraint "comments_parent_id_fkey" FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE; 215 | 216 | alter table "public"."comments" add constraint "comments_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id); 217 | 218 | set check_function_bodies = off; 219 | 220 | create or replace view "public"."comment_reactions_metadata" as SELECT comment_reactions.comment_id, 221 | comment_reactions.reaction_type, 222 | count(*) AS reaction_count, 223 | bool_or((comment_reactions.user_id = auth.uid())) AS active_for_user 224 | FROM comment_reactions 225 | GROUP BY comment_reactions.comment_id, comment_reactions.reaction_type 226 | ORDER BY comment_reactions.reaction_type; 227 | 228 | 229 | create or replace view "public"."comment_reactions_metadata_two" as SELECT comment_reactions.comment_id, 230 | comment_reactions.reaction_type, 231 | count(*) AS reaction_count 232 | FROM comment_reactions 233 | GROUP BY comment_reactions.comment_id, comment_reactions.reaction_type; 234 | 235 | 236 | create or replace view "public"."comments_with_metadata" as SELECT comments.id, 237 | comments.created_at, 238 | comments.topic, 239 | comments.comment, 240 | comments.user_id, 241 | comments.parent_id, 242 | comments.mentioned_user_ids, 243 | ( SELECT count(*) AS count 244 | FROM comments c 245 | WHERE (c.parent_id = comments.id)) AS replies_count 246 | FROM comments; 247 | 248 | 249 | create or replace view "public"."display_users" as SELECT users.id, 250 | COALESCE((users.raw_user_meta_data ->> 'name'::text), (users.raw_user_meta_data ->> 'full_name'::text), (users.raw_user_meta_data ->> 'user_name'::text)) AS name, 251 | COALESCE((users.raw_user_meta_data ->> 'avatar_url'::text), (users.raw_user_meta_data ->> 'avatar'::text)) AS avatar 252 | FROM auth.users; 253 | 254 | 255 | CREATE OR REPLACE FUNCTION public.pgrst_watch() 256 | RETURNS event_trigger 257 | LANGUAGE plpgsql 258 | AS $function$ 259 | BEGIN 260 | NOTIFY pgrst, 'reload schema'; 261 | END; 262 | $function$ 263 | ; 264 | 265 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "supabase-comments-extension", 3 | "author": "malerba118@gmail.com", 4 | "version": "0.0.2", 5 | "description": "A comment system for supabase", 6 | "main": "dist/index.js", 7 | "module": "dist/index.esm.js", 8 | "bin": { 9 | "supabase-comments-extension": "bin/script.js" 10 | }, 11 | "types": "dist/index.d.ts", 12 | "scripts": { 13 | "start": "NODE_ENV=production rollup -c -w", 14 | "build": "NODE_ENV=production rollup -c", 15 | "prepare": "NODE_ENV=production rollup -c" 16 | }, 17 | "license": "MIT", 18 | "peerDependencies": { 19 | "@supabase/supabase-js": ">1.0.0", 20 | "@supabase/ui": ">0.30.0", 21 | "react": ">17.0.0", 22 | "react-dom": ">17.0.0", 23 | "react-query": ">3.0.0" 24 | }, 25 | "devDependencies": { 26 | "@rollup/plugin-commonjs": "^20.0.0", 27 | "@rollup/plugin-json": "^4.1.0", 28 | "@rollup/plugin-node-resolve": "^13.0.4", 29 | "@supabase/react-data-grid": "7.1.0-beta.1", 30 | "@supabase/ui": "^0.36.3", 31 | "@tailwindcss/forms": "^0.3.3", 32 | "@testing-library/jest-dom": "^5.14.1", 33 | "@testing-library/react": "^12.0.0", 34 | "@types/color": "^3.0.2", 35 | "@types/file-saver": "^2.0.2", 36 | "@types/jest": "^27.0.1", 37 | "@types/md5": "^2.3.1", 38 | "@types/react": "^17.0.20", 39 | "@types/react-beautiful-dnd": "^13.0.0", 40 | "@types/react-dom": "^17.0.9", 41 | "@types/traverse": "^0.6.32", 42 | "autoprefixer": "^10.3.4", 43 | "cssnano": "^5.0.8", 44 | "identity-obj-proxy": "^3.0.0", 45 | "jest": "^26.6.3", 46 | "react": "*", 47 | "react-dom": "*", 48 | "rollup": "^2.56.3", 49 | "rollup-plugin-copy": "^3.4.0", 50 | "rollup-plugin-node-polyfills": "^0.2.1", 51 | "rollup-plugin-peer-deps-external": "^2.2.4", 52 | "rollup-plugin-postcss": "^4.0.1", 53 | "rollup-plugin-typescript2": "^0.30.0", 54 | "tailwindcss": "^3.0.14", 55 | "ts-jest": "^26.5.6", 56 | "typescript": "^4.4.2" 57 | }, 58 | "dependencies": { 59 | "@tiptap/extension-code-block-lowlight": "^2.0.0-beta.68", 60 | "@tiptap/extension-link": "^2.0.0-beta.36", 61 | "@tiptap/extension-mention": "^2.0.0-beta.92", 62 | "@tiptap/extension-placeholder": "^2.0.0-beta.47", 63 | "@tiptap/html": "^2.0.0-beta.162", 64 | "@tiptap/react": "^2.0.0-beta.105", 65 | "@tiptap/starter-kit": "^2.0.0-beta.171", 66 | "commander": "^8.3.0", 67 | "clsx": "^1.1.1", 68 | "color": "^4.2.0", 69 | "javascript-time-ago": "^2.3.10", 70 | "lowlight": "^2.4.0", 71 | "md5": "^2.3.0", 72 | "pg": "^8.7.1", 73 | "react-image": "^4.0.3", 74 | "react-time-ago": "^7.1.7", 75 | "tippy.js": "^6.3.7", 76 | "traverse": "^0.6.6" 77 | }, 78 | "prettier": { 79 | "printWidth": 80, 80 | "semi": true, 81 | "singleQuote": true, 82 | "trailingComma": "es5" 83 | }, 84 | "files": [ 85 | "dist", 86 | "bin" 87 | ], 88 | "release": { 89 | "branches": [ 90 | "main" 91 | ] 92 | }, 93 | "publishConfig": { 94 | "access": "public" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 2 | import nodeResolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import typescript from 'rollup-plugin-typescript2'; 5 | import postcss from 'rollup-plugin-postcss'; 6 | import json from '@rollup/plugin-json'; 7 | 8 | const packageJson = require('./package.json'); 9 | 10 | export default { 11 | input: 'src/index.ts', 12 | output: [ 13 | { 14 | file: packageJson.main, 15 | format: 'cjs', 16 | sourcemap: true, 17 | }, 18 | { 19 | file: packageJson.module, 20 | format: 'esm', 21 | sourcemap: true, 22 | }, 23 | ], 24 | watch: { 25 | include: 'src/**', 26 | }, 27 | plugins: [ 28 | peerDepsExternal(), 29 | nodeResolve(), 30 | json(), 31 | commonjs(), 32 | typescript({ useTsconfigDeclarationDir: true }), 33 | postcss({ 34 | config: { 35 | path: './postcss.config.js', 36 | ctx: {}, 37 | }, 38 | extensions: ['.css'], 39 | minimize: true, 40 | inject: { 41 | insertAt: 'top', 42 | }, 43 | }), 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { SupabaseClient } from '@supabase/supabase-js'; 2 | 3 | export interface CommentReactionMetadata { 4 | comment_id: string; 5 | reaction_type: string; 6 | reaction_count: number; 7 | active_for_user: boolean; 8 | } 9 | 10 | export interface DisplayUser { 11 | id: string; 12 | name: string; 13 | avatar: string; 14 | } 15 | 16 | export interface Comment { 17 | id: string; 18 | user_id: string; 19 | parent_id: string | null; 20 | topic: string; 21 | comment: string; 22 | created_at: string; 23 | replies_count: number; 24 | reactions_metadata: CommentReactionMetadata[]; 25 | user: DisplayUser; 26 | mentioned_user_ids: string[]; 27 | } 28 | 29 | export interface Reaction { 30 | type: string; 31 | created_at: string; 32 | label: string; 33 | url: string; 34 | metadata: any; 35 | } 36 | 37 | export interface CommentReaction { 38 | id: string; 39 | user_id: string; 40 | comment_id: string; 41 | reaction_type: string; 42 | created_at: string; 43 | user: DisplayUser; 44 | } 45 | 46 | export const assertResponseOk = (response: { error: any }) => { 47 | if (response.error) { 48 | throw new ApiError(response.error); 49 | } 50 | }; 51 | 52 | export class ApiError extends Error { 53 | type = 'ApiError'; 54 | message: string; 55 | details?: string; 56 | hint?: string; 57 | code?: string; 58 | constructor(error: any) { 59 | super(error.message); 60 | this.message = error.message; 61 | this.details = error.details; 62 | this.hint = error.hint; 63 | this.code = error.code; 64 | } 65 | } 66 | 67 | export interface GetCommentsOptions { 68 | topic: string; 69 | parentId: string | null; 70 | } 71 | 72 | export interface AddCommentPayload { 73 | comment: string; 74 | topic: string; 75 | parent_id: string | null; 76 | mentioned_user_ids: string[]; 77 | } 78 | 79 | export interface UpdateCommentPayload { 80 | comment: string; 81 | mentioned_user_ids: string[]; 82 | } 83 | 84 | export interface GetCommentReactionsOptions { 85 | reaction_type: string; 86 | comment_id: string; 87 | } 88 | 89 | export interface AddCommentReactionPayload { 90 | reaction_type: string; 91 | comment_id: string; 92 | } 93 | 94 | export interface RemoveCommentReactionPayload { 95 | reaction_type: string; 96 | comment_id: string; 97 | } 98 | 99 | export const createApiClient = (supabase: SupabaseClient) => { 100 | const getComments = async ({ 101 | topic, 102 | parentId = null, 103 | }: GetCommentsOptions): Promise => { 104 | const query = supabase 105 | .from('sce_comments_with_metadata') 106 | .select( 107 | '*,user:sce_display_users!user_id(*),reactions_metadata:sce_comment_reactions_metadata(*)' 108 | ) 109 | .eq('topic', topic) 110 | .order('created_at', { ascending: true }); 111 | 112 | if (parentId) { 113 | query.eq('parent_id', parentId); 114 | } else { 115 | query.is('parent_id', null); 116 | } 117 | const response = await query; 118 | assertResponseOk(response); 119 | return response.data as Comment[]; 120 | }; 121 | 122 | const getComment = async (id: string): Promise => { 123 | const query = supabase 124 | .from('sce_comments_with_metadata') 125 | .select( 126 | '*,user:sce_display_users!user_id(*),reactions_metadata:sce_comment_reactions_metadata(*)' 127 | ) 128 | .eq('id', id) 129 | .single(); 130 | 131 | const response = await query; 132 | assertResponseOk(response); 133 | return response.data as Comment; 134 | }; 135 | 136 | const addComment = async (payload: AddCommentPayload): Promise => { 137 | const query = supabase 138 | .from('sce_comments') 139 | .insert({ 140 | ...payload, 141 | user_id: supabase.auth.user()?.id, 142 | }) 143 | .single(); 144 | 145 | const response = await query; 146 | assertResponseOk(response); 147 | return response.data as Comment; 148 | }; 149 | 150 | const updateComment = async ( 151 | id: string, 152 | payload: UpdateCommentPayload 153 | ): Promise => { 154 | const query = supabase 155 | .from('sce_comments') 156 | .update(payload) 157 | .match({ id }) 158 | .single(); 159 | 160 | const response = await query; 161 | assertResponseOk(response); 162 | return response.data as Comment; 163 | }; 164 | 165 | const deleteComment = async (id: string): Promise => { 166 | const query = supabase.from('sce_comments').delete().match({ id }).single(); 167 | 168 | const response = await query; 169 | assertResponseOk(response); 170 | return response.data as Comment; 171 | }; 172 | 173 | const getReactions = async (): Promise => { 174 | const query = supabase 175 | .from('sce_reactions') 176 | .select('*') 177 | .order('type', { ascending: true }); 178 | 179 | const response = await query; 180 | assertResponseOk(response); 181 | return response.data as Reaction[]; 182 | }; 183 | 184 | const getReaction = async (type: string): Promise => { 185 | const query = supabase 186 | .from('sce_reactions') 187 | .select('*') 188 | .eq('type', type) 189 | .single(); 190 | 191 | const response = await query; 192 | assertResponseOk(response); 193 | return response.data as Reaction; 194 | }; 195 | 196 | const getCommentReactions = async ({ 197 | reaction_type, 198 | comment_id, 199 | }: GetCommentReactionsOptions): Promise => { 200 | const query = supabase 201 | .from('sce_comment_reactions') 202 | .select('*,user:sce_display_users!user_id(*)') 203 | .eq('comment_id', comment_id) 204 | .eq('reaction_type', reaction_type); 205 | 206 | const response = await query; 207 | assertResponseOk(response); 208 | return response.data as CommentReaction[]; 209 | }; 210 | 211 | const addCommentReaction = async ( 212 | payload: AddCommentReactionPayload 213 | ): Promise => { 214 | const query = supabase 215 | .from('sce_comment_reactions') 216 | .insert({ 217 | ...payload, 218 | user_id: supabase.auth.user()?.id, 219 | }) 220 | .single(); 221 | 222 | const response = await query; 223 | assertResponseOk(response); 224 | return response.data as CommentReaction; 225 | }; 226 | 227 | const removeCommentReaction = async ({ 228 | reaction_type, 229 | comment_id, 230 | }: RemoveCommentReactionPayload): Promise => { 231 | const query = supabase 232 | .from('sce_comment_reactions') 233 | .delete({ returning: 'representation' }) 234 | .match({ reaction_type, comment_id, user_id: supabase.auth.user()?.id }) 235 | .single(); 236 | 237 | const response = await query; 238 | assertResponseOk(response); 239 | return response.data as CommentReaction; 240 | }; 241 | 242 | const searchUsers = async (search: string): Promise => { 243 | const query = supabase 244 | .from('sce_display_users') 245 | .select('*') 246 | .ilike('name', `%${search}%`) 247 | .limit(5); 248 | 249 | const response = await query; 250 | assertResponseOk(response); 251 | return response.data as DisplayUser[]; 252 | }; 253 | 254 | const getUser = async (id: string): Promise => { 255 | const query = supabase 256 | .from('sce_display_users') 257 | .select('*') 258 | .eq('id', id) 259 | .single(); 260 | 261 | const response = await query; 262 | assertResponseOk(response); 263 | return response.data as DisplayUser; 264 | }; 265 | 266 | return { 267 | getComments, 268 | getComment, 269 | addComment, 270 | updateComment, 271 | deleteComment, 272 | getReactions, 273 | getReaction, 274 | getCommentReactions, 275 | addCommentReaction, 276 | removeCommentReaction, 277 | searchUsers, 278 | getUser, 279 | }; 280 | }; 281 | -------------------------------------------------------------------------------- /src/components/Auth.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { SupabaseClient, Provider } from '@supabase/supabase-js'; 3 | import { 4 | Input, 5 | Checkbox, 6 | Button, 7 | Space, 8 | Typography, 9 | Divider, 10 | IconKey, 11 | IconMail, 12 | IconLock, 13 | IconUser, 14 | Auth as SupabaseAuth, 15 | } from '@supabase/ui'; 16 | import md5 from 'md5'; 17 | // @ts-ignore 18 | import { jsx, jsxs } from 'react/jsx-runtime'; 19 | 20 | /** 21 | * This is a slightly modified version of @supabase/ui/Auth 22 | */ 23 | 24 | const size = 21; 25 | const google = () => 26 | jsx( 27 | 'svg', 28 | Object.assign( 29 | { 30 | width: size, 31 | 'aria-hidden': 'true', 32 | focusable: 'false', 33 | 'data-prefix': 'fab', 34 | 'data-icon': 'google', 35 | role: 'img', 36 | xmlns: 'http://www.w3.org/2000/svg', 37 | viewBox: '0 0 488 512', 38 | }, 39 | { 40 | children: jsx( 41 | 'path', 42 | { 43 | fill: 'currentColor', 44 | d: 'M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z', 45 | }, 46 | void 0 47 | ), 48 | } 49 | ), 50 | void 0 51 | ); 52 | const facebook = () => 53 | jsx( 54 | 'svg', 55 | Object.assign( 56 | { 57 | width: size, 58 | 'aria-hidden': 'true', 59 | focusable: 'false', 60 | 'data-prefix': 'fab', 61 | 'data-icon': 'facebook-square', 62 | role: 'img', 63 | xmlns: 'http://www.w3.org/2000/svg', 64 | viewBox: '0 0 448 512', 65 | }, 66 | { 67 | children: jsx( 68 | 'path', 69 | { 70 | fill: 'currentColor', 71 | d: 'M400 32H48A48 48 0 0 0 0 80v352a48 48 0 0 0 48 48h137.25V327.69h-63V256h63v-54.64c0-62.15 37-96.48 93.67-96.48 27.14 0 55.52 4.84 55.52 4.84v61h-31.27c-30.81 0-40.42 19.12-40.42 38.73V256h68.78l-11 71.69h-57.78V480H400a48 48 0 0 0 48-48V80a48 48 0 0 0-48-48z', 72 | }, 73 | void 0 74 | ), 75 | } 76 | ), 77 | void 0 78 | ); 79 | const twitter = () => 80 | jsx( 81 | 'svg', 82 | Object.assign( 83 | { 84 | width: size, 85 | 'aria-hidden': 'true', 86 | focusable: 'false', 87 | 'data-prefix': 'fab', 88 | 'data-icon': 'twitter', 89 | className: 'svg-inline--fa fa-twitter fa-w-16', 90 | role: 'img', 91 | xmlns: 'http://www.w3.org/2000/svg', 92 | viewBox: '0 0 512 512', 93 | }, 94 | { 95 | children: jsx( 96 | 'path', 97 | { 98 | fill: 'currentColor', 99 | d: 'M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z', 100 | }, 101 | void 0 102 | ), 103 | } 104 | ), 105 | void 0 106 | ); 107 | const apple = () => 108 | jsx( 109 | 'svg', 110 | Object.assign( 111 | { 112 | width: size, 113 | 'aria-hidden': 'true', 114 | focusable: 'false', 115 | 'data-prefix': 'fab', 116 | 'data-icon': 'apple', 117 | className: 'svg-inline--fa fa-apple fa-w-16', 118 | role: 'img', 119 | xmlns: 'http://www.w3.org/2000/svg', 120 | viewBox: '0 0 512 512', 121 | }, 122 | { 123 | children: jsx( 124 | 'path', 125 | { 126 | fill: 'currentColor', 127 | d: 'M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z', 128 | }, 129 | void 0 130 | ), 131 | } 132 | ), 133 | void 0 134 | ); 135 | const github = () => 136 | jsx( 137 | 'svg', 138 | Object.assign( 139 | { 140 | width: size, 141 | 'aria-hidden': 'true', 142 | focusable: 'false', 143 | 'data-prefix': 'fab', 144 | 'data-icon': 'github', 145 | className: 'svg-inline--fa fa-github fa-w-16', 146 | role: 'img', 147 | xmlns: 'http://www.w3.org/2000/svg', 148 | viewBox: '0 0 496 512', 149 | }, 150 | { 151 | children: jsx( 152 | 'path', 153 | { 154 | fill: 'currentColor', 155 | d: 'M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z', 156 | }, 157 | void 0 158 | ), 159 | } 160 | ), 161 | void 0 162 | ); 163 | const gitlab = () => 164 | jsx( 165 | 'svg', 166 | Object.assign( 167 | { 168 | width: size, 169 | 'aria-hidden': 'true', 170 | focusable: 'false', 171 | 'data-prefix': 'fab', 172 | 'data-icon': 'gitlab', 173 | className: 'svg-inline--fa fa-gitlab fa-w-16', 174 | role: 'img', 175 | xmlns: 'http://www.w3.org/2000/svg', 176 | viewBox: '0 0 512 512', 177 | }, 178 | { 179 | children: jsx( 180 | 'path', 181 | { 182 | fill: 'currentColor', 183 | d: 'M105.2 24.9c-3.1-8.9-15.7-8.9-18.9 0L29.8 199.7h132c-.1 0-56.6-174.8-56.6-174.8zM.9 287.7c-2.6 8 .3 16.9 7.1 22l247.9 184-226.2-294zm160.8-88l94.3 294 94.3-294zm349.4 88l-28.8-88-226.3 294 247.9-184c6.9-5.1 9.7-14 7.2-22zM425.7 24.9c-3.1-8.9-15.7-8.9-18.9 0l-56.6 174.8h132z', 184 | }, 185 | void 0 186 | ), 187 | } 188 | ), 189 | void 0 190 | ); 191 | const bitbucket = () => 192 | jsx( 193 | 'svg', 194 | Object.assign( 195 | { 196 | width: size, 197 | 'aria-hidden': 'true', 198 | focusable: 'false', 199 | 'data-prefix': 'fab', 200 | 'data-icon': 'bitbucket', 201 | className: 'svg-inline--fa fa-bitbucket fa-w-16', 202 | role: 'img', 203 | xmlns: 'http://www.w3.org/2000/svg', 204 | viewBox: '0 0 512 512', 205 | }, 206 | { 207 | children: jsx( 208 | 'path', 209 | { 210 | fill: 'currentColor', 211 | d: 'M22.2 32A16 16 0 0 0 6 47.8a26.35 26.35 0 0 0 .2 2.8l67.9 412.1a21.77 21.77 0 0 0 21.3 18.2h325.7a16 16 0 0 0 16-13.4L505 50.7a16 16 0 0 0-13.2-18.3 24.58 24.58 0 0 0-2.8-.2L22.2 32zm285.9 297.8h-104l-28.1-147h157.3l-25.2 147z', 212 | }, 213 | void 0 214 | ), 215 | } 216 | ), 217 | void 0 218 | ); 219 | const discord = () => 220 | jsx( 221 | 'svg', 222 | Object.assign( 223 | { 224 | width: size, 225 | height: size, 226 | viewBox: '0 0 71 55', 227 | fill: 'none', 228 | xmlns: 'http://www.w3.org/2000/svg', 229 | }, 230 | { 231 | children: jsx( 232 | 'g', 233 | Object.assign( 234 | { 235 | clipPath: 'url(#clip0)', 236 | }, 237 | { 238 | children: jsx( 239 | 'path', 240 | { 241 | d: 'M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z', 242 | fill: 'currentColor', 243 | }, 244 | void 0 245 | ), 246 | } 247 | ), 248 | void 0 249 | ), 250 | } 251 | ), 252 | void 0 253 | ); 254 | const azure = () => 255 | jsxs( 256 | 'svg', 257 | Object.assign( 258 | { 259 | width: size, 260 | height: size, 261 | viewBox: '0 0 96 96', 262 | xmlns: 'http://www.w3.org/2000/svg', 263 | }, 264 | { 265 | children: [ 266 | jsxs( 267 | 'defs', 268 | { 269 | children: [ 270 | jsxs( 271 | 'linearGradient', 272 | Object.assign( 273 | { 274 | id: 'e399c19f-b68f-429d-b176-18c2117ff73c', 275 | x1: '-1032.172', 276 | x2: '-1059.213', 277 | y1: '145.312', 278 | y2: '65.426', 279 | gradientTransform: 'matrix(1 0 0 -1 1075 158)', 280 | gradientUnits: 'userSpaceOnUse', 281 | }, 282 | { 283 | children: [ 284 | jsx( 285 | 'stop', 286 | { 287 | offset: '0', 288 | stopColor: '#fff', 289 | }, 290 | void 0 291 | ), 292 | jsx( 293 | 'stop', 294 | { 295 | offset: '1', 296 | stopColor: '#fff', 297 | }, 298 | void 0 299 | ), 300 | ], 301 | } 302 | ), 303 | void 0 304 | ), 305 | jsxs( 306 | 'linearGradient', 307 | Object.assign( 308 | { 309 | id: 'ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15', 310 | x1: '-1023.725', 311 | x2: '-1029.98', 312 | y1: '108.083', 313 | y2: '105.968', 314 | gradientTransform: 'matrix(1 0 0 -1 1075 158)', 315 | gradientUnits: 'userSpaceOnUse', 316 | }, 317 | { 318 | children: [ 319 | jsx( 320 | 'stop', 321 | { 322 | offset: '0', 323 | stopOpacity: '.3', 324 | }, 325 | void 0 326 | ), 327 | jsx( 328 | 'stop', 329 | { 330 | offset: '.071', 331 | stopOpacity: '.2', 332 | }, 333 | void 0 334 | ), 335 | jsx( 336 | 'stop', 337 | { 338 | offset: '.321', 339 | stopOpacity: '.1', 340 | }, 341 | void 0 342 | ), 343 | jsx( 344 | 'stop', 345 | { 346 | offset: '.623', 347 | stopOpacity: '.05', 348 | }, 349 | void 0 350 | ), 351 | jsx( 352 | 'stop', 353 | { 354 | offset: '1', 355 | stopOpacity: '0', 356 | }, 357 | void 0 358 | ), 359 | ], 360 | } 361 | ), 362 | void 0 363 | ), 364 | jsxs( 365 | 'linearGradient', 366 | Object.assign( 367 | { 368 | id: 'a7fee970-a784-4bb1-af8d-63d18e5f7db9', 369 | x1: '-1027.165', 370 | x2: '-997.482', 371 | y1: '147.642', 372 | y2: '68.561', 373 | gradientTransform: 'matrix(1 0 0 -1 1075 158)', 374 | gradientUnits: 'userSpaceOnUse', 375 | }, 376 | { 377 | children: [ 378 | jsx( 379 | 'stop', 380 | { 381 | offset: '0', 382 | stopColor: '#fff', 383 | }, 384 | void 0 385 | ), 386 | jsx( 387 | 'stop', 388 | { 389 | offset: '1', 390 | stopColor: '#fff', 391 | }, 392 | void 0 393 | ), 394 | ], 395 | } 396 | ), 397 | void 0 398 | ), 399 | ], 400 | }, 401 | void 0 402 | ), 403 | jsx( 404 | 'path', 405 | { 406 | fill: 'url(#e399c19f-b68f-429d-b176-18c2117ff73c)', 407 | d: 'M33.338 6.544h26.038l-27.03 80.087a4.152 4.152 0 0 1-3.933 2.824H8.149a4.145 4.145 0 0 1-3.928-5.47L29.404 9.368a4.152 4.152 0 0 1 3.934-2.825z', 408 | }, 409 | void 0 410 | ), 411 | jsx( 412 | 'path', 413 | { 414 | fill: 'currentColor', 415 | d: 'M71.175 60.261h-41.29a1.911 1.911 0 0 0-1.305 3.309l26.532 24.764a4.171 4.171 0 0 0 2.846 1.121h23.38z', 416 | }, 417 | void 0 418 | ), 419 | jsx( 420 | 'path', 421 | { 422 | fill: 'currentColor', 423 | d: 'M33.338 6.544a4.118 4.118 0 0 0-3.943 2.879L4.252 83.917a4.14 4.14 0 0 0 3.908 5.538h20.787a4.443 4.443 0 0 0 3.41-2.9l5.014-14.777 17.91 16.705a4.237 4.237 0 0 0 2.666.972H81.24L71.024 60.261l-29.781.007L59.47 6.544z', 424 | }, 425 | void 0 426 | ), 427 | jsx( 428 | 'path', 429 | { 430 | fill: 'currentColor', 431 | d: 'M66.595 9.364a4.145 4.145 0 0 0-3.928-2.82H33.648a4.146 4.146 0 0 1 3.928 2.82l25.184 74.62a4.146 4.146 0 0 1-3.928 5.472h29.02a4.146 4.146 0 0 0 3.927-5.472z', 432 | }, 433 | void 0 434 | ), 435 | ], 436 | } 437 | ), 438 | void 0 439 | ); 440 | const twitch = () => 441 | jsx( 442 | 'svg', 443 | Object.assign( 444 | { 445 | width: size, 446 | 'aria-hidden': 'true', 447 | focusable: 'false', 448 | 'data-prefix': 'fab', 449 | 'data-icon': 'twitch', 450 | className: 'svg-inline--fa fa-twitch fa-w-16', 451 | role: 'img', 452 | xmlns: 'http://www.w3.org/2000/svg', 453 | viewBox: '0 0 512 512', 454 | }, 455 | { 456 | children: jsx( 457 | 'path', 458 | { 459 | fill: 'currentColor', 460 | d: 'M391.17,103.47H352.54v109.7h38.63ZM285,103H246.37V212.75H285ZM120.83,0,24.31,91.42V420.58H140.14V512l96.53-91.42h77.25L487.69,256V0ZM449.07,237.75l-77.22,73.12H294.61l-67.6,64v-64H140.14V36.58H449.07Z', 461 | }, 462 | void 0 463 | ), 464 | } 465 | ), 466 | void 0 467 | ); 468 | 469 | const SocialIcons = { 470 | apple, 471 | azure, 472 | bitbucket, 473 | discord, 474 | facebook, 475 | github, 476 | gitlab, 477 | google, 478 | twitch, 479 | twitter, 480 | }; 481 | 482 | const getGravatarUrl = async (email: string): Promise => { 483 | const hash = md5(email); 484 | const res = await fetch(`https://secure.gravatar.com/${hash}.json`, { 485 | credentials: 'omit', 486 | }); 487 | const data = await res.json(); 488 | return data?.entry?.[0]?.thumbnailUrl ?? null; 489 | }; 490 | 491 | const VIEWS: ViewsMap = { 492 | SIGN_IN: 'sign_in', 493 | SIGN_UP: 'sign_up', 494 | FORGOTTEN_PASSWORD: 'forgotten_password', 495 | MAGIC_LINK: 'magic_link', 496 | UPDATE_PASSWORD: 'update_password', 497 | }; 498 | 499 | interface ViewsMap { 500 | [key: string]: ViewType; 501 | } 502 | 503 | type ViewType = 504 | | 'sign_in' 505 | | 'sign_up' 506 | | 'forgotten_password' 507 | | 'magic_link' 508 | | 'update_password'; 509 | 510 | type RedirectTo = undefined | string; 511 | 512 | export interface AuthProps { 513 | supabaseClient: SupabaseClient; 514 | className?: string; 515 | children?: React.ReactNode; 516 | style?: React.CSSProperties; 517 | socialLayout?: 'horizontal' | 'vertical'; 518 | socialColors?: boolean; 519 | socialButtonSize?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge'; 520 | providers?: Provider[]; 521 | verticalSocialLayout?: any; 522 | view?: ViewType; 523 | redirectTo?: RedirectTo; 524 | onlyThirdPartyProviders?: boolean; 525 | magicLink?: boolean; 526 | } 527 | 528 | function Auth({ 529 | supabaseClient, 530 | className, 531 | style, 532 | socialLayout = 'vertical', 533 | socialColors = false, 534 | socialButtonSize = 'medium', 535 | providers, 536 | view = 'sign_in', 537 | redirectTo, 538 | onlyThirdPartyProviders = false, 539 | magicLink = false, 540 | }: AuthProps): JSX.Element | null { 541 | const [authView, setAuthView] = useState(view); 542 | const [defaultEmail, setDefaultEmail] = useState(''); 543 | const [defaultPassword, setDefaultPassword] = useState(''); 544 | 545 | const verticalSocialLayout = socialLayout === 'vertical' ? true : false; 546 | 547 | let containerClasses = [] as string[]; 548 | if (className) { 549 | containerClasses.push(className); 550 | } 551 | 552 | const Container = (props: any) => ( 553 |
554 | 555 | 566 | {!onlyThirdPartyProviders && props.children} 567 | 568 |
569 | ); 570 | 571 | useEffect(() => { 572 | // handle view override 573 | setAuthView(view); 574 | }, [view]); 575 | 576 | switch (authView) { 577 | case VIEWS.SIGN_IN: 578 | case VIEWS.SIGN_UP: 579 | return ( 580 | 581 | 593 | 594 | ); 595 | case VIEWS.FORGOTTEN_PASSWORD: 596 | return ( 597 | 598 | 603 | 604 | ); 605 | 606 | case VIEWS.MAGIC_LINK: 607 | return ( 608 | 609 | 614 | 615 | ); 616 | 617 | case VIEWS.UPDATE_PASSWORD: 618 | return ( 619 | 620 | 621 | 622 | ); 623 | 624 | default: 625 | return null; 626 | } 627 | } 628 | 629 | function SocialAuth({ 630 | className, 631 | style, 632 | supabaseClient, 633 | children, 634 | socialLayout = 'vertical', 635 | socialColors = false, 636 | socialButtonSize, 637 | providers, 638 | verticalSocialLayout, 639 | redirectTo, 640 | onlyThirdPartyProviders, 641 | magicLink, 642 | ...props 643 | }: AuthProps) { 644 | const buttonStyles: any = { 645 | azure: { 646 | backgroundColor: '#008AD7', 647 | color: 'white', 648 | }, 649 | bitbucket: { 650 | backgroundColor: '#205081', 651 | color: 'white', 652 | }, 653 | facebook: { 654 | backgroundColor: '#4267B2', 655 | color: 'white', 656 | }, 657 | github: { 658 | backgroundColor: '#333', 659 | color: 'white', 660 | }, 661 | gitlab: { 662 | backgroundColor: '#FC6D27', 663 | }, 664 | google: { 665 | backgroundColor: '#ce4430', 666 | color: 'white', 667 | }, 668 | twitter: { 669 | backgroundColor: '#1DA1F2', 670 | color: 'white', 671 | }, 672 | apple: { 673 | backgroundColor: '#000', 674 | color: 'white', 675 | }, 676 | discord: { 677 | backgroundColor: '#404fec', 678 | color: 'white', 679 | }, 680 | twitch: { 681 | backgroundColor: '#9146ff', 682 | color: 'white', 683 | }, 684 | }; 685 | const [loading, setLoading] = useState(false); 686 | const [error, setError] = useState(''); 687 | 688 | const handleProviderSignIn = async (provider: Provider) => { 689 | setLoading(true); 690 | const { error } = await supabaseClient.auth.signIn( 691 | { provider }, 692 | { redirectTo } 693 | ); 694 | if (error) setError(error.message); 695 | setLoading(false); 696 | }; 697 | 698 | return ( 699 | 700 | {providers && providers.length > 0 && ( 701 | 702 | 703 | Sign in with 704 | 705 | {providers.map((provider) => { 706 | // @ts-ignore 707 | const AuthIcon = SocialIcons[provider]; 708 | return ( 709 |
713 | 729 |
730 | ); 731 | })} 732 |
733 |
734 | {!onlyThirdPartyProviders && or continue with} 735 |
736 | )} 737 |
738 | ); 739 | } 740 | 741 | function EmailAuth({ 742 | authView, 743 | defaultEmail, 744 | defaultPassword, 745 | id, 746 | setAuthView, 747 | setDefaultEmail, 748 | setDefaultPassword, 749 | supabaseClient, 750 | redirectTo, 751 | magicLink, 752 | }: { 753 | authView: ViewType; 754 | defaultEmail: string; 755 | defaultPassword: string; 756 | id: 'auth-sign-up' | 'auth-sign-in'; 757 | setAuthView: any; 758 | setDefaultEmail: (email: string) => void; 759 | setDefaultPassword: (password: string) => void; 760 | supabaseClient: SupabaseClient; 761 | redirectTo?: RedirectTo; 762 | magicLink?: boolean; 763 | }) { 764 | const isMounted = useRef(true); 765 | const [email, setEmail] = useState(defaultEmail); 766 | const [password, setPassword] = useState(defaultPassword); 767 | const [displayName, setDisplayName] = useState(''); 768 | const [rememberMe, setRememberMe] = useState(false); 769 | const [error, setError] = useState(''); 770 | const [loading, setLoading] = useState(false); 771 | const [message, setMessage] = useState(''); 772 | 773 | useEffect(() => { 774 | setEmail(defaultEmail); 775 | setPassword(defaultPassword); 776 | 777 | return () => { 778 | isMounted.current = false; 779 | }; 780 | }, [authView]); 781 | 782 | const handleSubmit = async (e: React.FormEvent) => { 783 | e.preventDefault(); 784 | setError(''); 785 | setLoading(true); 786 | switch (authView) { 787 | case 'sign_in': 788 | const { error: signInError } = await supabaseClient.auth.signIn( 789 | { 790 | email, 791 | password, 792 | }, 793 | { redirectTo } 794 | ); 795 | if (signInError) setError(signInError.message); 796 | break; 797 | case 'sign_up': 798 | const avatar = await getGravatarUrl(email).catch(() => null); 799 | const { 800 | user: signUpUser, 801 | session: signUpSession, 802 | error: signUpError, 803 | } = await supabaseClient.auth.signUp( 804 | { 805 | email, 806 | password, 807 | }, 808 | { 809 | redirectTo, 810 | data: { 811 | name: displayName, 812 | avatar, 813 | }, 814 | } 815 | ); 816 | if (signUpError) setError(signUpError.message); 817 | // Check if session is null -> email confirmation setting is turned on 818 | else if (signUpUser && !signUpSession) 819 | setMessage('Check your email for the confirmation link.'); 820 | break; 821 | } 822 | 823 | /* 824 | * it is possible the auth component may have been unmounted at this point 825 | * check if component is mounted before setting a useState 826 | */ 827 | if (isMounted.current) setLoading(false); 828 | }; 829 | 830 | const handleViewChange = (newView: ViewType) => { 831 | setDefaultEmail(email); 832 | setDefaultPassword(password); 833 | setAuthView(newView); 834 | }; 835 | 836 | return ( 837 |
838 | 839 | 840 | } 846 | onChange={(e: React.ChangeEvent) => 847 | setEmail(e.target.value) 848 | } 849 | /> 850 | } 857 | onChange={(e: React.ChangeEvent) => 858 | setPassword(e.target.value) 859 | } 860 | /> 861 | {authView === 'sign_up' && ( 862 | } 870 | onChange={(e: React.ChangeEvent) => 871 | setDisplayName(e.target.value) 872 | } 873 | /> 874 | )} 875 | 876 | 877 | 878 | ) => 883 | setRememberMe(value.target.checked) 884 | } 885 | /> 886 | {authView === VIEWS.SIGN_IN && ( 887 | ) => { 890 | e.preventDefault(); 891 | setAuthView(VIEWS.FORGOTTEN_PASSWORD); 892 | }} 893 | > 894 | Forgot your password? 895 | 896 | )} 897 | 898 | 913 | 914 | 915 | {authView === VIEWS.SIGN_IN && magicLink && ( 916 | ) => { 919 | e.preventDefault(); 920 | setAuthView(VIEWS.MAGIC_LINK); 921 | }} 922 | > 923 | Sign in with magic link 924 | 925 | )} 926 | {authView === VIEWS.SIGN_IN ? ( 927 | ) => { 930 | e.preventDefault(); 931 | handleViewChange(VIEWS.SIGN_UP); 932 | }} 933 | > 934 | Don't have an account? Sign up 935 | 936 | ) : ( 937 | ) => { 940 | e.preventDefault(); 941 | handleViewChange(VIEWS.SIGN_IN); 942 | }} 943 | > 944 | Do you have an account? Sign in 945 | 946 | )} 947 | {message && {message}} 948 | {error && {error}} 949 | 950 | 951 |
952 | ); 953 | } 954 | 955 | Auth.ForgottenPassword = SupabaseAuth.ForgottenPassword; 956 | Auth.UpdatePassword = SupabaseAuth.UpdatePassword; 957 | Auth.MagicLink = SupabaseAuth.MagicLink; 958 | Auth.UserContextProvider = SupabaseAuth.UserContextProvider; 959 | Auth.useUser = SupabaseAuth.useUser; 960 | 961 | export default Auth; 962 | -------------------------------------------------------------------------------- /src/components/AuthModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentProps, FC, useEffect } from 'react'; 2 | import { Modal } from '@supabase/ui'; 3 | import Auth from './Auth'; 4 | import { useSupabaseClient } from './CommentsProvider'; 5 | import { useLatestRef } from '../hooks/useLatestRef'; 6 | import { Session } from '@supabase/gotrue-js'; 7 | import clsx from 'clsx'; 8 | 9 | export interface AuthModalProps 10 | extends Omit, 'supabaseClient'> { 11 | visible: boolean; 12 | onClose?: () => void; 13 | onAuthenticate?: (session: Session) => void; 14 | title?: string; 15 | description?: string; 16 | } 17 | 18 | const AuthModal: FC = ({ 19 | visible, 20 | onAuthenticate, 21 | onClose, 22 | view = 'sign_in', 23 | title = 'Please Sign In', 24 | description, 25 | className, 26 | ...otherProps 27 | }) => { 28 | const supabase = useSupabaseClient(); 29 | const onAuthenticateRef = useLatestRef(onAuthenticate); 30 | useEffect(() => { 31 | const subscription = supabase.auth.onAuthStateChange((ev, session) => { 32 | if (ev === 'SIGNED_IN' && session) { 33 | onAuthenticateRef.current?.(session); 34 | } 35 | }); 36 | return () => { 37 | subscription.data?.unsubscribe(); 38 | }; 39 | }, [supabase]); 40 | 41 | return ( 42 | 51 |
0 55 | ? null 56 | : '!-mt-4' 57 | )} 58 | > 59 | 60 |
61 |
62 | ); 63 | }; 64 | 65 | export default AuthModal; 66 | -------------------------------------------------------------------------------- /src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from '@supabase/ui'; 2 | import clsx from 'clsx'; 3 | import React, { FC } from 'react'; 4 | import { useImage } from 'react-image'; 5 | 6 | export interface AvatarProps 7 | extends Omit, 'size'> { 8 | src?: string; 9 | size?: 'sm' | 'lg'; 10 | } 11 | 12 | const Avatar: FC = ({ 13 | src, 14 | className, 15 | size = 'lg', 16 | ...otherProps 17 | }) => { 18 | const image = useImage({ srcList: src || [], useSuspense: false }); 19 | 20 | return ( 21 |
29 | {image.src && ( 30 | 34 | )} 35 | 36 | {image.isLoading &&
} 37 | {image.error && ( 38 |
39 | 45 | 49 | 53 | 54 |
55 | )} 56 |
57 | ); 58 | }; 59 | 60 | export default Avatar; 61 | -------------------------------------------------------------------------------- /src/components/Comment.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Loading, 3 | Dropdown, 4 | Typography, 5 | IconMoreVertical, 6 | Button, 7 | } from '@supabase/ui'; 8 | import React, { FC, useEffect, useRef, useState } from 'react'; 9 | import type * as api from '../api'; 10 | import { 11 | useComment, 12 | useDeleteComment, 13 | useUpdateComment, 14 | useAddReaction, 15 | useRemoveReaction, 16 | useUncontrolledState, 17 | } from '../hooks'; 18 | import Editor, { EditorRefHandle } from './Editor'; 19 | import TimeAgo from './TimeAgo'; 20 | import Comments from './Comments'; 21 | import ReplyManagerProvider, { useReplyManager } from './ReplyManagerProvider'; 22 | import { useCommentsContext } from './CommentsProvider'; 23 | import { getMentionedUserIds } from '../utils'; 24 | import useAuthUtils from '../hooks/useAuthUtils'; 25 | import User from './User'; 26 | 27 | interface CommentMenuProps { 28 | onEdit: () => void; 29 | onDelete: () => void; 30 | } 31 | 32 | const CommentMenu: FC = ({ onEdit, onDelete }) => { 33 | return ( 34 | onEdit()}> 37 | Edit 38 | , 39 | onDelete()}> 40 | Delete 41 | , 42 | ]} 43 | > 44 | 45 | 46 | ); 47 | }; 48 | 49 | export interface CommentProps { 50 | id: string; 51 | } 52 | 53 | const Comment: FC = ({ id }) => { 54 | const query = useComment({ id }); 55 | 56 | return ( 57 |
58 | {query.isLoading && ( 59 |
60 |
61 | {null} 62 |
63 |
64 | )} 65 | {query.data && !query.data.parent_id && ( 66 | 67 | 68 | 69 | )} 70 | {query.data && query.data.parent_id && ( 71 | 72 | )} 73 |
74 | ); 75 | }; 76 | 77 | interface CommentDataProps { 78 | comment: api.Comment; 79 | } 80 | 81 | const CommentData: FC = ({ comment }) => { 82 | const editorRef = useRef(null); 83 | const context = useCommentsContext(); 84 | const [editing, setEditing] = useState(false); 85 | const [repliesVisible, setRepliesVisible] = useState(false); 86 | const commentState = useUncontrolledState({ defaultValue: comment.comment }); 87 | const replyManager = useReplyManager(); 88 | const mutations = { 89 | addReaction: useAddReaction(), 90 | removeReaction: useRemoveReaction(), 91 | updateComment: useUpdateComment(), 92 | deleteComment: useDeleteComment(), 93 | }; 94 | const { isAuthenticated, runIfAuthenticated, auth } = useAuthUtils(); 95 | 96 | const isReplyingTo = replyManager?.replyingTo?.id === comment.id; 97 | 98 | useEffect(() => { 99 | if (comment.parent_id) { 100 | return; 101 | } 102 | // if we're at the top level use replyingTo 103 | // to control expansion state 104 | if (replyManager?.replyingTo) { 105 | setRepliesVisible(true); 106 | } else { 107 | // setRepliesVisible(false); 108 | } 109 | }, [replyManager?.replyingTo, comment.parent_id]); 110 | 111 | useEffect(() => { 112 | if (mutations.updateComment.isSuccess) { 113 | setEditing(false); 114 | } 115 | }, [mutations.updateComment.isSuccess]); 116 | 117 | const isReply = !!comment.parent_id; 118 | 119 | const activeReactions = comment.reactions_metadata.reduce( 120 | (set, reactionMetadata) => { 121 | if (reactionMetadata.active_for_user) { 122 | set.add(reactionMetadata.reaction_type); 123 | } 124 | return set; 125 | }, 126 | new Set() 127 | ); 128 | 129 | const toggleReaction = (reactionType: string) => { 130 | runIfAuthenticated(() => { 131 | if (!activeReactions.has(reactionType)) { 132 | mutations.addReaction.mutate({ 133 | commentId: comment.id, 134 | reactionType, 135 | }); 136 | } else { 137 | mutations.removeReaction.mutate({ 138 | commentId: comment.id, 139 | reactionType, 140 | }); 141 | } 142 | }); 143 | }; 144 | 145 | return ( 146 |
147 |
148 | 149 |
150 |
151 |
152 |
153 | {comment.user_id === auth?.user?.id && ( 154 | { 156 | setEditing(true); 157 | }} 158 | onDelete={() => { 159 | mutations.deleteComment.mutate({ id: comment.id }); 160 | }} 161 | /> 162 | )} 163 |
164 |

165 | { 168 | context.onUserClick?.(comment.user); 169 | }} 170 | > 171 | {comment.user.name} 172 | 173 |

174 |

175 | {!editing && ( 176 | 181 | )} 182 | {editing && ( 183 | { 188 | commentState.setValue(val); 189 | }} 190 | autoFocus={!!replyManager?.replyingTo} 191 | actions={ 192 |

193 | 203 | 220 |
221 | } 222 | /> 223 | )} 224 |

225 |

226 | 227 |

228 |
229 |
230 |
231 | 236 |
237 |
238 | {!isReply && ( 239 |
setRepliesVisible((prev) => !prev)} 241 | className="cursor-pointer" 242 | tabIndex={0} 243 | > 244 | {!repliesVisible && ( 245 |

view replies ({comment.replies_count})

246 | )} 247 | {repliesVisible &&

hide replies

} 248 |
249 | )} 250 | {!isReplyingTo && ( 251 |

{ 255 | replyManager?.setReplyingTo(comment); 256 | }} 257 | > 258 | reply 259 |

260 | )} 261 | {isReplyingTo && ( 262 |

{ 266 | replyManager?.setReplyingTo(null); 267 | }} 268 | > 269 | cancel 270 |

271 | )} 272 |
273 |
274 |
275 | {repliesVisible && !isReply && ( 276 |
277 | 278 |
279 | )} 280 |
281 |
282 |
283 | ); 284 | }; 285 | 286 | export default Comment; 287 | -------------------------------------------------------------------------------- /src/components/CommentReaction.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { Loading, Modal, Typography } from '@supabase/ui'; 3 | import { useCommentReactions } from '../hooks'; 4 | import Avatar from './Avatar'; 5 | import Reaction from './Reaction'; 6 | import { CommentReactionMetadata } from '../api'; 7 | import clsx from 'clsx'; 8 | import User from './User'; 9 | 10 | const CommentReactionsModal = ({ 11 | visible, 12 | commentId, 13 | reactionType, 14 | onClose, 15 | }: any) => { 16 | const query = useCommentReactions( 17 | { 18 | commentId, 19 | reactionType, 20 | }, 21 | { enabled: visible } 22 | ); 23 | 24 | return ( 25 |
26 | onClose()} 30 | onConfirm={() => onClose()} 31 | showIcon={false} 32 | size="tiny" 33 | hideFooter 34 | > 35 |
36 | {query.isLoading && ( 37 |
38 |
39 | {null} 40 |
41 |
42 | )} 43 | {query.data?.map((commentReaction) => ( 44 | 51 | ))} 52 |
53 |
54 |
55 | ); 56 | }; 57 | 58 | export interface CommentReactionProps { 59 | metadata: CommentReactionMetadata; 60 | toggleReaction: (reactionType: string) => void; 61 | } 62 | 63 | const CommentReaction: FC = ({ 64 | metadata, 65 | toggleReaction, 66 | }) => { 67 | const [showDetails, setShowDetails] = useState(false); 68 | 69 | return ( 70 | <> 71 | setShowDetails(false)} 76 | size="small" 77 | /> 78 |
86 |
{ 90 | toggleReaction(metadata.reaction_type); 91 | }} 92 | > 93 | 94 |
95 |

96 | setShowDetails(true)}> 97 | {metadata.reaction_count} 98 | 99 |

100 |
101 | 102 | ); 103 | }; 104 | 105 | export default CommentReaction; 106 | -------------------------------------------------------------------------------- /src/components/CommentReactions.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import type * as api from '../api'; 3 | import CommentReaction from './CommentReaction'; 4 | import ReactionSelector from './ReactionSelector'; 5 | 6 | export interface CommentReactionsProps { 7 | activeReactions: Set; 8 | reactionsMetadata: api.CommentReactionMetadata[]; 9 | toggleReaction: (reactionType: string) => void; 10 | } 11 | 12 | export const CommentReactions: FC = ({ 13 | activeReactions, 14 | reactionsMetadata, 15 | toggleReaction, 16 | }) => { 17 | return ( 18 |
19 | 23 | {reactionsMetadata.map((reactionMetadata) => ( 24 | 29 | ))} 30 |
31 | ); 32 | }; 33 | 34 | export default CommentReactions; 35 | -------------------------------------------------------------------------------- /src/components/Comments.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useLayoutEffect, useRef, useState } from 'react'; 2 | import { Loading, Button, Typography, IconAlertCircle } from '@supabase/ui'; 3 | import clsx from 'clsx'; 4 | import { 5 | useComments, 6 | useReactions, 7 | useAddComment, 8 | useUncontrolledState, 9 | } from '../hooks'; 10 | import Editor, { EditorRefHandle } from './Editor'; 11 | import Comment from './Comment'; 12 | import { useReplyManager } from './ReplyManagerProvider'; 13 | import { getMentionedUserIds } from '../utils'; 14 | import useAuthUtils from '../hooks/useAuthUtils'; 15 | import { useCommentsContext } from './CommentsProvider'; 16 | import useUser from '../hooks/useUser'; 17 | import User from './User'; 18 | 19 | export interface CommentsProps { 20 | topic: string; 21 | parentId?: string | null; 22 | } 23 | 24 | const Comments: FC = ({ topic, parentId = null }) => { 25 | const editorRef = useRef(null); 26 | const context = useCommentsContext(); 27 | const [layoutReady, setLayoutReady] = useState(false); 28 | const replyManager = useReplyManager(); 29 | const commentState = useUncontrolledState({ defaultValue: '' }); 30 | const { auth, isAuthenticated, runIfAuthenticated } = useAuthUtils(); 31 | 32 | const queries = { 33 | comments: useComments({ topic, parentId }), 34 | user: useUser({ id: auth.user?.id! }, { enabled: !!auth.user?.id }), 35 | }; 36 | 37 | const mutations = { 38 | addComment: useAddComment(), 39 | }; 40 | 41 | // preload reactions 42 | useReactions(); 43 | 44 | useEffect(() => { 45 | if (replyManager?.replyingTo) { 46 | commentState.setDefaultValue( 47 | ` ` 48 | ); 49 | } else { 50 | commentState.setDefaultValue(''); 51 | } 52 | }, [replyManager?.replyingTo]); 53 | 54 | useEffect(() => { 55 | if (mutations.addComment.isSuccess) { 56 | replyManager?.setReplyingTo(null); 57 | commentState.setDefaultValue(''); 58 | } 59 | }, [mutations.addComment.isSuccess]); 60 | 61 | useLayoutEffect(() => { 62 | if (queries.comments.isSuccess) { 63 | // this is neccessary because tiptap on first render has different height than on second render 64 | // which causes layout shift. this just hides content on the first render to avoid ugly layout 65 | // shift that happens when comment height changes. 66 | setLayoutReady(true); 67 | } 68 | }, [queries.comments.isSuccess]); 69 | 70 | const user = queries.user.data; 71 | 72 | return ( 73 |
74 | {queries.comments.isLoading && ( 75 |
76 |
77 | {null} 78 |
79 |
80 | )} 81 | {queries.comments.isError && ( 82 |
83 |
84 | 85 | 86 | 87 | Unable to load comments. 88 |
89 |
90 | )} 91 | {queries.comments.data && ( 92 |
98 |
99 | {queries.comments.data.map((comment) => ( 100 | 101 | ))} 102 |
103 |
104 |
105 | 106 |
107 |
108 | { 113 | commentState.setValue(val); 114 | }} 115 | autoFocus={!!replyManager?.replyingTo} 116 | actions={ 117 | 139 | } 140 | /> 141 |
142 |
143 |
144 | )} 145 |
146 | ); 147 | }; 148 | 149 | export default Comments; 150 | -------------------------------------------------------------------------------- /src/components/CommentsProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Query, QueryClient, QueryClientProvider } from 'react-query'; 2 | import React, { 3 | ComponentType, 4 | createContext, 5 | FC, 6 | useContext, 7 | useEffect, 8 | useMemo, 9 | } from 'react'; 10 | import Auth from './Auth'; 11 | import { SupabaseClient } from '@supabase/supabase-js'; 12 | import { ApiError, DisplayUser } from '../api'; 13 | import { useCssPalette } from '..'; 14 | import { 15 | CommentReactionsProps, 16 | CommentReactions as DefaultCommentReactions, 17 | } from './CommentReactions'; 18 | 19 | const defaultQueryClient = new QueryClient(); 20 | 21 | const SupabaseClientContext = createContext(null); 22 | 23 | export const useSupabaseClient = () => { 24 | const supabaseClient = useContext(SupabaseClientContext); 25 | if (!supabaseClient) { 26 | throw new Error( 27 | 'No supabase client found. Make sure this code is contained in a CommentsProvider.' 28 | ); 29 | } 30 | return supabaseClient; 31 | }; 32 | 33 | export interface ComponentOverrideOptions { 34 | CommentReactions?: ComponentType; 35 | } 36 | 37 | export interface CommentsContextApi { 38 | onAuthRequested?: () => void; 39 | onUserClick?: (user: DisplayUser) => void; 40 | mode: 'light' | 'dark'; 41 | components: Required; 42 | enableMentions: boolean; 43 | } 44 | 45 | const CommentsContext = createContext(null); 46 | 47 | export const useCommentsContext = () => { 48 | const context = useContext(CommentsContext); 49 | if (!context) { 50 | throw new Error( 51 | 'CommentsProvider not found. Make sure this code is contained in a CommentsProvider.' 52 | ); 53 | } 54 | return context; 55 | }; 56 | 57 | export interface CommentsProviderProps { 58 | queryClient?: QueryClient; 59 | supabaseClient: SupabaseClient; 60 | onAuthRequested?: () => void; 61 | onUserClick?: (user: DisplayUser) => void; 62 | mode?: 'light' | 'dark'; 63 | accentColor?: string; 64 | onError?: (error: ApiError, query: Query) => void; 65 | components?: ComponentOverrideOptions; 66 | enableMentions?: boolean; 67 | } 68 | 69 | const CommentsProvider: FC = ({ 70 | queryClient = defaultQueryClient, 71 | supabaseClient, 72 | children, 73 | onAuthRequested, 74 | onUserClick, 75 | mode = 'light', 76 | accentColor = 'rgb(36, 180, 126)', 77 | onError, 78 | components, 79 | enableMentions = true, 80 | }) => { 81 | components; 82 | const context = useMemo( 83 | () => ({ 84 | onAuthRequested, 85 | onUserClick, 86 | mode, 87 | enableMentions, 88 | components: { 89 | CommentReactions: 90 | components?.CommentReactions || DefaultCommentReactions, 91 | }, 92 | }), 93 | [ 94 | onAuthRequested, 95 | onUserClick, 96 | mode, 97 | enableMentions, 98 | components?.CommentReactions, 99 | ] 100 | ); 101 | 102 | useEffect(() => { 103 | const subscription = supabaseClient.auth.onAuthStateChange(() => { 104 | // refetch all queries when auth changes 105 | queryClient.invalidateQueries(); 106 | }); 107 | return () => { 108 | subscription.data?.unsubscribe(); 109 | }; 110 | }, [queryClient, supabaseClient]); 111 | 112 | useCssPalette(accentColor, 'sce-accent'); 113 | 114 | useEffect(() => { 115 | document.body.classList.add(mode); 116 | return () => { 117 | document.body.classList.remove(mode); 118 | }; 119 | }, [mode]); 120 | 121 | // Convenience api for handling errors 122 | useEffect(() => { 123 | const queryCache = queryClient.getQueryCache(); 124 | const originalErrorHandler = queryCache.config.onError; 125 | queryCache.config.onError = (error, query) => { 126 | onError?.(error as ApiError, query); 127 | originalErrorHandler?.(error, query); 128 | }; 129 | }, [queryClient]); 130 | 131 | return ( 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | 139 | 140 | 141 | ); 142 | }; 143 | 144 | export default CommentsProvider; 145 | -------------------------------------------------------------------------------- /src/components/Editor.module.css: -------------------------------------------------------------------------------- 1 | .editor { 2 | height: 136px; 3 | border-width: 2px; 4 | border-radius: 8px; 5 | position: relative; 6 | } 7 | 8 | /* tiptap takes a second to load which can cause flicker, 9 | min-height helps mitigate that layout shift */ 10 | .viewer { 11 | height: auto; 12 | min-height: 1rem; 13 | position: relative; 14 | } 15 | 16 | .viewer :global(.tiptap-editor) { 17 | /* height: auto; 18 | padding: 0; 19 | overflow: auto; 20 | border: none; 21 | border-radius: 0; */ 22 | } 23 | 24 | .editor :global(.tiptap-editor) { 25 | height: 100%; 26 | padding: 8px; 27 | overflow: auto; 28 | border: none !important; 29 | outline: none !important; 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, forwardRef, ReactNode, useImperativeHandle } from 'react'; 2 | import { IconBold, IconCode, IconItalic, IconList } from '@supabase/ui'; 3 | import { useEditor, EditorContent } from '@tiptap/react'; 4 | import clsx from 'clsx'; 5 | import Link from '@tiptap/extension-link'; 6 | import StarterKit from '@tiptap/starter-kit'; 7 | import MentionsExtension from './Mentions'; 8 | import Placeholder from '@tiptap/extension-placeholder'; 9 | import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 10 | import { Editor as IEditor } from '@tiptap/core'; 11 | 12 | import styles from './Editor.module.css'; 13 | // @ts-ignore 14 | import { lowlight } from 'lowlight'; 15 | import { useCommentsContext } from './CommentsProvider'; 16 | 17 | interface EditorProps { 18 | defaultValue: string; 19 | onChange?: (value: string) => void; 20 | readOnly?: boolean; 21 | autoFocus?: boolean; 22 | actions?: ReactNode; 23 | ref?: any; 24 | } 25 | 26 | export interface EditorRefHandle { 27 | editor: () => IEditor | null; 28 | } 29 | 30 | const Editor: FC = forwardRef( 31 | ( 32 | { 33 | defaultValue, 34 | onChange, 35 | readOnly = false, 36 | autoFocus = false, 37 | actions = null, 38 | }, 39 | ref 40 | ) => { 41 | const context = useCommentsContext(); 42 | const extensions: any[] = [ 43 | StarterKit, 44 | Placeholder.configure({ 45 | placeholder: 'Write a message...', 46 | }), 47 | CodeBlockLowlight.configure({ lowlight, defaultLanguage: null }), 48 | Link.configure({ 49 | HTMLAttributes: { 50 | class: 'tiptap-link', 51 | }, 52 | openOnClick: false, 53 | }), 54 | ]; 55 | if (context.enableMentions) { 56 | extensions.push(MentionsExtension); 57 | } 58 | const editor = useEditor({ 59 | editable: !readOnly, 60 | extensions, 61 | content: defaultValue, 62 | onUpdate: ({ editor }) => { 63 | onChange?.(editor.getHTML()); 64 | }, 65 | autofocus: autoFocus ? 'end' : false, 66 | editorProps: { 67 | attributes: { 68 | class: 'tiptap-editor', 69 | }, 70 | }, 71 | }); 72 | 73 | useImperativeHandle(ref, () => ({ 74 | editor: () => { 75 | return editor; 76 | }, 77 | })); 78 | 79 | const activeStyles = 'bg-alpha-10'; 80 | 81 | return ( 82 |
88 | 92 | {!readOnly && ( 93 |
99 |
{ 102 | editor?.chain().focus().toggleBold().run(); 103 | e.preventDefault(); 104 | }} 105 | title="Bold" 106 | > 107 | 113 |
114 |
{ 117 | editor?.chain().focus().toggleItalic().run(); 118 | e.preventDefault(); 119 | }} 120 | title="Italic" 121 | > 122 | 128 |
129 |
{ 132 | editor?.chain().focus().toggleCodeBlock().run(); 133 | e.preventDefault(); 134 | }} 135 | title="Code Block" 136 | > 137 | 143 |
144 |
{ 147 | editor?.chain().focus().toggleBulletList().run(); 148 | e.preventDefault(); 149 | }} 150 | title="Bullet List" 151 | > 152 | 166 | 167 | 168 |
169 |
{ 172 | editor?.chain().focus().toggleOrderedList().run(); 173 | e.preventDefault(); 174 | }} 175 | title="Numbered List" 176 | > 177 | 191 | 192 | 193 |
194 |
195 |
{actions}
196 |
197 | )} 198 |
199 | ); 200 | } 201 | ); 202 | 203 | export default Editor; 204 | -------------------------------------------------------------------------------- /src/components/Mentions.tsx: -------------------------------------------------------------------------------- 1 | import tippy from 'tippy.js'; 2 | import { ReactRenderer } from '@tiptap/react'; 3 | import React, { 4 | useState, 5 | useEffect, 6 | forwardRef, 7 | useImperativeHandle, 8 | } from 'react'; 9 | import { useSearchUsers } from '..'; 10 | import { Loading, Menu, Typography } from '@supabase/ui'; 11 | import Mention from '@tiptap/extension-mention'; 12 | import User from './User'; 13 | 14 | const MentionList = forwardRef((props: any, ref) => { 15 | const [selectedIndex, setSelectedIndex] = useState(0); 16 | const query = useSearchUsers({ search: props.query }); 17 | 18 | useEffect(() => setSelectedIndex(0), [query.data]); 19 | 20 | const selectItem = (index: number) => { 21 | const item = query.data?.[index]; 22 | 23 | if (item) { 24 | props.command({ id: item.id, label: item.name }); 25 | } 26 | }; 27 | 28 | const upHandler = () => { 29 | if (!query.data) { 30 | return; 31 | } 32 | setSelectedIndex( 33 | (selectedIndex + query.data.length - 1) % query.data.length 34 | ); 35 | }; 36 | 37 | const downHandler = () => { 38 | if (!query.data) { 39 | return; 40 | } 41 | setSelectedIndex((selectedIndex + 1) % query.data.length); 42 | }; 43 | 44 | const enterHandler = () => { 45 | selectItem(selectedIndex); 46 | }; 47 | 48 | useImperativeHandle(ref, () => ({ 49 | onKeyDown: ({ event }: any) => { 50 | if (event.key === 'ArrowUp') { 51 | upHandler(); 52 | return true; 53 | } 54 | 55 | if (event.key === 'ArrowDown') { 56 | downHandler(); 57 | return true; 58 | } 59 | 60 | if (event.key === 'Enter') { 61 | enterHandler(); 62 | return true; 63 | } 64 | 65 | return false; 66 | }, 67 | })); 68 | 69 | if (!props.editor.options.editable) { 70 | return null; 71 | } 72 | 73 | return ( 74 | 75 | {query.isLoading && {null}} 76 | {query.data && 77 | query.data.length > 0 && 78 | query.data.map((item: any, index: number) => ( 79 | selectItem(index)} 83 | > 84 | 85 | 86 | ))} 87 | {query.data && query.data.length === 0 && ( 88 |
89 | No results. 90 |
91 | )} 92 |
93 | ); 94 | }); 95 | 96 | export const suggestionConfig = { 97 | items: () => [], 98 | render: () => { 99 | let reactRenderer: any; 100 | let popup: any; 101 | 102 | return { 103 | onStart: (props: any) => { 104 | reactRenderer = new ReactRenderer(MentionList, { 105 | props, 106 | editor: props.editor, 107 | }); 108 | 109 | popup = tippy('body', { 110 | getReferenceClientRect: props.clientRect, 111 | appendTo: () => document.body, 112 | content: reactRenderer.element, 113 | showOnCreate: true, 114 | interactive: true, 115 | trigger: 'manual', 116 | placement: 'top-start', 117 | }); 118 | }, 119 | onUpdate(props: any) { 120 | reactRenderer.updateProps(props); 121 | 122 | popup[0].setProps({ 123 | getReferenceClientRect: props.clientRect, 124 | }); 125 | }, 126 | onKeyDown(props: any) { 127 | return reactRenderer.ref?.onKeyDown(props); 128 | }, 129 | onExit() { 130 | popup[0].destroy(); 131 | reactRenderer.destroy(); 132 | }, 133 | }; 134 | }, 135 | }; 136 | 137 | const MentionsExtension = Mention.configure({ 138 | HTMLAttributes: { 139 | class: 'mention', 140 | }, 141 | suggestion: suggestionConfig, 142 | }); 143 | 144 | export default MentionsExtension; 145 | -------------------------------------------------------------------------------- /src/components/Reaction.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Image } from '@supabase/ui'; 3 | import { useReaction } from '../hooks'; 4 | import clsx from 'clsx'; 5 | 6 | export interface ReactionProps { 7 | type: string; 8 | } 9 | 10 | const Reaction: FC = ({ type }) => { 11 | const query = useReaction({ type }); 12 | 13 | return ( 14 |
19 | {query.data?.label} 24 |
25 | ); 26 | }; 27 | 28 | export default Reaction; 29 | -------------------------------------------------------------------------------- /src/components/ReactionSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Dropdown, IconPlus, Typography } from '@supabase/ui'; 2 | import clsx from 'clsx'; 3 | import React, { FC } from 'react'; 4 | import useReactions from '../hooks/useReactions'; 5 | import Reaction from './Reaction'; 6 | 7 | export interface ReactionSelectorProps { 8 | activeReactions: Set; 9 | toggleReaction: (reactionType: string) => void; 10 | } 11 | 12 | const ReactionSelector: FC = ({ 13 | activeReactions, 14 | toggleReaction, 15 | }) => { 16 | const reactions = useReactions(); 17 | return ( 18 | ( 20 | { 23 | toggleReaction(reaction.type); 24 | }} 25 | icon={ 26 |
34 | 35 |
36 | } 37 | > 38 | 39 | {reaction.label} 40 | 41 |
42 | ))} 43 | > 44 |
45 | 46 |
47 |
48 | ); 49 | }; 50 | 51 | export default ReactionSelector; 52 | -------------------------------------------------------------------------------- /src/components/ReplyManagerProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, FC, useContext, useMemo, useState } from 'react'; 2 | import type * as api from '../api'; 3 | 4 | interface ReplyManagerContextApi { 5 | replyingTo: api.Comment | null; 6 | setReplyingTo: (comment: api.Comment | null) => void; 7 | } 8 | 9 | const ReplyManagerContext = createContext(null); 10 | 11 | export const useReplyManager = () => { 12 | return useContext(ReplyManagerContext); 13 | }; 14 | 15 | const ReplyManagerProvider: FC = ({ children }) => { 16 | const [replyingTo, setReplyingTo] = useState(null); 17 | 18 | const api = useMemo( 19 | () => ({ 20 | replyingTo, 21 | setReplyingTo, 22 | }), 23 | [replyingTo, setReplyingTo] 24 | ); 25 | return ( 26 | 27 | {children} 28 | 29 | ); 30 | }; 31 | 32 | export default ReplyManagerProvider; 33 | -------------------------------------------------------------------------------- /src/components/TimeAgo.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useMemo } from 'react'; 2 | import ReactTimeAgo from 'react-time-ago'; 3 | import _TimeAgo from 'javascript-time-ago'; 4 | // @ts-ignore 5 | import en from 'javascript-time-ago/locale/en.json'; 6 | 7 | _TimeAgo.addDefaultLocale(en); 8 | 9 | interface TimeAgoProps { 10 | date: string | Date; 11 | locale: string; 12 | } 13 | 14 | const TimeAgo: FC = ({ date, locale = 'en-US' }) => { 15 | const _date = useMemo(() => new Date(date), [date]); 16 | 17 | return ; 18 | }; 19 | 20 | export default TimeAgo; 21 | -------------------------------------------------------------------------------- /src/components/User.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Typography } from '@supabase/ui'; 3 | import useUser from '../hooks/useUser'; 4 | import Avatar from './Avatar'; 5 | import { useCommentsContext } from './CommentsProvider'; 6 | import clsx from 'clsx'; 7 | 8 | export interface UserProps { 9 | id?: string; 10 | size?: 'sm' | 'lg'; 11 | showName?: boolean; 12 | showAvatar?: boolean; 13 | propagateClick?: boolean; 14 | className?: string; 15 | } 16 | 17 | const User: FC = ({ 18 | id, 19 | size = 'lg', 20 | showName = true, 21 | showAvatar = true, 22 | propagateClick = true, 23 | className, 24 | }) => { 25 | const context = useCommentsContext(); 26 | const query = useUser({ id: id! }, { enabled: !!id }); 27 | 28 | const user = query.data; 29 | 30 | return ( 31 |
32 | {showAvatar && ( 33 | { 37 | if (user && propagateClick) { 38 | context.onUserClick?.(user); 39 | } 40 | }} 41 | src={user?.avatar} 42 | size={size} 43 | /> 44 | )} 45 | {user && showName && ( 46 | 47 | { 51 | if (propagateClick) { 52 | context.onUserClick?.(user); 53 | } 54 | }} 55 | > 56 | {user.name} 57 | 58 | 59 | )} 60 |
61 | ); 62 | }; 63 | 64 | export default User; 65 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Auth, AuthProps } from './Auth'; 2 | export { default as AuthModal, AuthModalProps } from './AuthModal'; 3 | export { default as Avatar, AvatarProps } from './Avatar'; 4 | export { default as Comments, CommentsProps } from './Comments'; 5 | export { default as Comment, CommentProps } from './Comment'; 6 | export { default as Reaction, ReactionProps } from './Reaction'; 7 | export { default as User, UserProps } from './User'; 8 | export { 9 | default as CommentsProvider, 10 | CommentsProviderProps, 11 | CommentsContextApi, 12 | ComponentOverrideOptions, 13 | } from './CommentsProvider'; 14 | export { 15 | default as CommentReaction, 16 | CommentReactionProps, 17 | } from './CommentReaction'; 18 | export { 19 | default as CommentReactions, 20 | CommentReactionsProps, 21 | } from './CommentReactions'; 22 | export { 23 | default as ReactionSelector, 24 | ReactionSelectorProps, 25 | } from './ReactionSelector'; 26 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .sce-comments .sbui-loading-spinner { 6 | color: var(--sce-accent-400) !important; 7 | } 8 | 9 | .tiptap-editor .mention { 10 | font-size: 0.92rem; 11 | font-weight: 400; 12 | border: 1px solid rgba(0, 0, 0, 0.2); 13 | border-radius: 8px; 14 | padding: 2px 3px; 15 | user-select: text !important; 16 | } 17 | 18 | .dark .tiptap-editor .mention { 19 | border: 1px solid rgba(255, 255, 255, 0.2); 20 | } 21 | 22 | .tiptap-editor code { 23 | background-color: rgba(#616161, 0.1); 24 | color: #616161; 25 | } 26 | 27 | .tiptap-editor pre { 28 | background: #0d0d0d; 29 | color: #fff; 30 | font-family: 'JetBrainsMono', monospace; 31 | padding: 0.75rem 1rem; 32 | border-radius: 0.5rem; 33 | margin: 0.25rem 0rem !important; 34 | } 35 | 36 | .tiptap-editor pre code { 37 | color: inherit; 38 | padding: 0; 39 | background: none; 40 | font-size: 0.8rem; 41 | } 42 | 43 | .tiptap-editor pre { 44 | background: #0d0d0d; 45 | color: #fff; 46 | font-family: 'JetBrainsMono', monospace; 47 | padding: 0.75rem 1rem; 48 | border-radius: 0.5rem; 49 | } 50 | 51 | .tiptap-editor code { 52 | color: inherit; 53 | padding: 0; 54 | background: none; 55 | font-size: 0.8rem; 56 | } 57 | 58 | .tiptap-editor .hljs-comment, 59 | .hljs-quote { 60 | color: #616161 !important; 61 | } 62 | 63 | .tiptap-editor .hljs-variable, 64 | .hljs-template-variable, 65 | .hljs-attribute, 66 | .hljs-tag, 67 | .hljs-name, 68 | .hljs-regexp, 69 | .hljs-link, 70 | .hljs-name, 71 | .hljs-selector-id, 72 | .hljs-selector-class { 73 | color: #f98181 !important; 74 | } 75 | 76 | .tiptap-editor .hljs-number, 77 | .hljs-meta, 78 | .hljs-built_in, 79 | .hljs-builtin-name, 80 | .hljs-literal, 81 | .hljs-type, 82 | .hljs-params { 83 | color: #fbbc88 !important; 84 | } 85 | 86 | .tiptap-editor .hljs-string, 87 | .hljs-symbol, 88 | .hljs-bullet { 89 | color: #b9f18d !important; 90 | } 91 | 92 | .tiptap-editor .hljs-title, 93 | .hljs-section { 94 | color: #fad594 !important; 95 | } 96 | 97 | .tiptap-editor .hljs-keyword, 98 | .hljs-selector-tag { 99 | color: #70cff8 !important; 100 | } 101 | 102 | .tiptap-editor .hljs-emphasis { 103 | font-style: italic !important; 104 | } 105 | 106 | .tiptap-editor .hljs-strong { 107 | font-weight: 700; 108 | } 109 | 110 | .tiptap-editor .tiptap-link { 111 | text-decoration: underline; 112 | filter: brightness(110%); 113 | } 114 | 115 | .ProseMirror p.is-editor-empty:first-child::before { 116 | content: attr(data-placeholder); 117 | position: absolute; 118 | top: 8px; 119 | left: 8px; 120 | pointer-events: none; 121 | font-size: 1rem; 122 | @apply text-alpha-40; 123 | } 124 | 125 | .tiptap-editor ul { 126 | padding: 0 1rem; 127 | list-style-type: disc; 128 | } 129 | 130 | .tiptap-editor ol { 131 | padding: 0 1rem; 132 | list-style-type: decimal; 133 | } 134 | 135 | .text-alpha { 136 | @apply text-[color:rgba(0,0,0,var(--tw-text-opacity))] dark:text-[color:rgba(255,255,255,var(--tw-text-opacity))]; 137 | } 138 | 139 | .text-alpha-10 { 140 | @apply text-alpha !text-opacity-10; 141 | } 142 | 143 | .text-alpha-20 { 144 | @apply text-alpha !text-opacity-20; 145 | } 146 | 147 | .text-alpha-30 { 148 | @apply text-alpha !text-opacity-30; 149 | } 150 | 151 | .text-alpha-40 { 152 | @apply text-alpha !text-opacity-40; 153 | } 154 | 155 | .text-alpha-50 { 156 | @apply text-alpha !text-opacity-50; 157 | } 158 | 159 | .text-alpha-60 { 160 | @apply text-alpha !text-opacity-60; 161 | } 162 | 163 | .text-alpha-70 { 164 | @apply text-alpha !text-opacity-70; 165 | } 166 | 167 | .text-alpha-80 { 168 | @apply text-alpha !text-opacity-80; 169 | } 170 | 171 | .text-alpha-90 { 172 | @apply text-alpha !text-opacity-90; 173 | } 174 | 175 | .text-alpha-100 { 176 | @apply text-alpha !text-opacity-100; 177 | } 178 | 179 | .border-alpha { 180 | @apply border-[color:rgba(0,0,0,var(--tw-border-opacity))] dark:border-[color:rgba(255,255,255,var(--tw-border-opacity))]; 181 | } 182 | 183 | .border-alpha-10 { 184 | @apply border-alpha !border-opacity-10; 185 | } 186 | 187 | .border-alpha-20 { 188 | @apply border-alpha !border-opacity-20; 189 | } 190 | 191 | .border-alpha-30 { 192 | @apply border-alpha !border-opacity-30; 193 | } 194 | 195 | .border-alpha-40 { 196 | @apply border-alpha !border-opacity-40; 197 | } 198 | 199 | .border-alpha-50 { 200 | @apply border-alpha !border-opacity-50; 201 | } 202 | 203 | .border-alpha-60 { 204 | @apply border-alpha !border-opacity-60; 205 | } 206 | 207 | .border-alpha-70 { 208 | @apply border-alpha !border-opacity-70; 209 | } 210 | 211 | .border-alpha-80 { 212 | @apply border-alpha !border-opacity-80; 213 | } 214 | 215 | .border-alpha-90 { 216 | @apply border-alpha !border-opacity-90; 217 | } 218 | 219 | .border-alpha-100 { 220 | @apply border-alpha !border-opacity-100; 221 | } 222 | 223 | .bg-alpha { 224 | @apply bg-[color:rgba(0,0,0,var(--tw-bg-opacity))] dark:bg-[color:rgba(255,255,255,var(--tw-bg-opacity))]; 225 | } 226 | 227 | .bg-alpha-5 { 228 | @apply bg-alpha !bg-opacity-5; 229 | } 230 | 231 | .bg-alpha-10 { 232 | @apply bg-alpha !bg-opacity-10; 233 | } 234 | 235 | .bg-alpha-20 { 236 | @apply bg-alpha !bg-opacity-20; 237 | } 238 | 239 | .bg-alpha-30 { 240 | @apply bg-alpha !bg-opacity-30; 241 | } 242 | 243 | .bg-alpha-40 { 244 | @apply bg-alpha !bg-opacity-40; 245 | } 246 | 247 | .bg-alpha-50 { 248 | @apply bg-alpha !bg-opacity-50; 249 | } 250 | 251 | .bg-alpha-60 { 252 | @apply bg-alpha !bg-opacity-60; 253 | } 254 | 255 | .bg-alpha-70 { 256 | @apply bg-alpha !bg-opacity-70; 257 | } 258 | 259 | .bg-alpha-80 { 260 | @apply bg-alpha !bg-opacity-80; 261 | } 262 | 263 | .bg-alpha-90 { 264 | @apply bg-alpha !bg-opacity-90; 265 | } 266 | 267 | .bg-alpha-100 { 268 | @apply bg-alpha !bg-opacity-100; 269 | } 270 | 271 | .tiptap-editor .sbui-btn:disabled { 272 | opacity: 0.66; 273 | } 274 | 275 | .tiptap-editor .sbui-btn-primary { 276 | background-color: var(--sce-accent-500) !important; 277 | color: var(--sce-accent-50) !important; 278 | } 279 | 280 | .tiptap-editor .sbui-btn-primary:hover { 281 | background-color: var(--sce-accent-400) !important; 282 | } 283 | 284 | .dark .tiptap-editor .sbui-btn-primary:hover { 285 | background-color: var(--sce-accent-600) !important; 286 | } 287 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useComment } from './useComment'; 2 | export { default as useComments } from './useComments'; 3 | export { default as useAddComment } from './useAddComment'; 4 | export { default as useUpdateComment } from './useUpdateComment'; 5 | export { default as useDeleteComment } from './useDeleteComment'; 6 | export { default as useReaction } from './useReaction'; 7 | export { default as useReactions } from './useReactions'; 8 | export { default as useAddReaction } from './useAddReaction'; 9 | export { default as useRemoveReaction } from './useRemoveReaction'; 10 | export { default as useCommentReactions } from './useCommentReactions'; 11 | export { default as useSearchUsers } from './useSearchUsers'; 12 | export { default as useUncontrolledState } from './useUncontrolledState'; 13 | export { default as useCssPalette } from './useCssPalette'; 14 | export { default as useUser } from './useUser'; 15 | -------------------------------------------------------------------------------- /src/hooks/useAddComment.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from 'react-query'; 2 | import useApi from './useApi'; 3 | 4 | interface UseAddCommentPayload { 5 | comment: string; 6 | topic: string; 7 | parentId: string | null; 8 | mentionedUserIds: string[]; 9 | } 10 | 11 | const useAddComment = () => { 12 | const queryClient = useQueryClient(); 13 | const api = useApi(); 14 | 15 | return useMutation( 16 | ({ comment, topic, parentId, mentionedUserIds }: UseAddCommentPayload) => { 17 | return api.addComment({ 18 | comment, 19 | topic, 20 | parent_id: parentId, 21 | mentioned_user_ids: mentionedUserIds, 22 | }); 23 | }, 24 | { 25 | onSuccess: (data, params) => { 26 | queryClient.invalidateQueries([ 27 | 'comments', 28 | { topic: params.topic, parentId: params.parentId }, 29 | ]); 30 | }, 31 | } 32 | ); 33 | }; 34 | 35 | export default useAddComment; 36 | -------------------------------------------------------------------------------- /src/hooks/useAddReaction.ts: -------------------------------------------------------------------------------- 1 | import { Item } from '@supabase/ui/dist/cjs/components/Accordion/Accordion'; 2 | import { useMutation, useQueryClient } from 'react-query'; 3 | import { Comment, CommentReactionMetadata } from '../api'; 4 | import useApi from './useApi'; 5 | 6 | interface UseAddReactionPayload { 7 | reactionType: string; 8 | commentId: string; 9 | } 10 | 11 | // Do a little surgery on the comment and manually increment the reaction count 12 | // or add a new item to the array if the reaction was not previously in the 13 | // reactions array. 14 | const addOrIncrement = (reactionType: string, comment: Comment): Comment => { 15 | const isInArray = !!comment.reactions_metadata.find( 16 | (val) => val.reaction_type === reactionType 17 | ); 18 | let newArray = [...comment.reactions_metadata]; 19 | if (!isInArray) { 20 | newArray.push({ 21 | comment_id: comment.id, 22 | reaction_type: reactionType, 23 | reaction_count: 1, 24 | active_for_user: true, 25 | }); 26 | } else { 27 | newArray = newArray.map((item) => { 28 | if (item.reaction_type === reactionType) { 29 | return { 30 | ...item, 31 | reaction_count: item.reaction_count + 1, 32 | active_for_user: true, 33 | }; 34 | } else { 35 | return item; 36 | } 37 | }); 38 | } 39 | newArray.sort((a, b) => a.reaction_type.localeCompare(b.reaction_type)); 40 | return { 41 | ...comment, 42 | reactions_metadata: newArray, 43 | }; 44 | }; 45 | 46 | const useAddReaction = () => { 47 | const api = useApi(); 48 | const queryClient = useQueryClient(); 49 | 50 | return useMutation( 51 | (payload: UseAddReactionPayload) => { 52 | return api.addCommentReaction({ 53 | reaction_type: payload.reactionType, 54 | comment_id: payload.commentId, 55 | }); 56 | }, 57 | { 58 | onSuccess: (data, params) => { 59 | // Manually patch the comment while it refetches 60 | queryClient.setQueryData( 61 | ['comments', params.commentId], 62 | (prev: Comment) => addOrIncrement(params.reactionType, prev) 63 | ); 64 | queryClient.invalidateQueries(['comments', params.commentId]); 65 | queryClient.invalidateQueries([ 66 | 'comment-reactions', 67 | { commentId: params.commentId, reactionType: params.reactionType }, 68 | ]); 69 | }, 70 | } 71 | ); 72 | }; 73 | 74 | export default useAddReaction; 75 | -------------------------------------------------------------------------------- /src/hooks/useApi.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { createApiClient } from '../api'; 3 | import { useSupabaseClient } from '../components/CommentsProvider'; 4 | 5 | const useApi = () => { 6 | const supabase = useSupabaseClient(); 7 | const api = useMemo(() => createApiClient(supabase), [supabase]); 8 | return api; 9 | }; 10 | 11 | export default useApi; 12 | -------------------------------------------------------------------------------- /src/hooks/useAuthUtils.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from '@supabase/ui'; 2 | import { useCommentsContext } from '../components/CommentsProvider'; 3 | 4 | // run callback if authenticated 5 | const useAuthUtils = () => { 6 | const auth = Auth.useUser(); 7 | const { onAuthRequested } = useCommentsContext(); 8 | 9 | const isAuthenticated = !!auth.session; 10 | 11 | const runIfAuthenticated = (callback: Function) => { 12 | if (!isAuthenticated) { 13 | onAuthRequested?.(); 14 | } else { 15 | callback(); 16 | } 17 | }; 18 | 19 | return { 20 | runIfAuthenticated, 21 | isAuthenticated, 22 | auth, 23 | }; 24 | }; 25 | 26 | export default useAuthUtils; 27 | -------------------------------------------------------------------------------- /src/hooks/useComment.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import useApi from './useApi'; 3 | 4 | interface UseCommentQuery { 5 | id: string; 6 | } 7 | 8 | interface UseCommentOptions { 9 | enabled?: boolean; 10 | } 11 | 12 | const useComment = ( 13 | { id }: UseCommentQuery, 14 | options: UseCommentOptions = {} 15 | ) => { 16 | const api = useApi(); 17 | 18 | return useQuery( 19 | ['comments', id], 20 | () => { 21 | return api.getComment(id); 22 | }, 23 | { 24 | staleTime: Infinity, 25 | enabled: options.enabled, 26 | } 27 | ); 28 | }; 29 | 30 | export default useComment; 31 | -------------------------------------------------------------------------------- /src/hooks/useCommentReactions.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from 'react-query'; 2 | import useApi from './useApi'; 3 | 4 | interface UseCommentReactionsQuery { 5 | commentId: string; 6 | reactionType: string; 7 | } 8 | 9 | interface UseCommentReactionsOptions { 10 | enabled?: boolean; 11 | } 12 | 13 | const useCommentReactions = ( 14 | { commentId, reactionType }: UseCommentReactionsQuery, 15 | options: UseCommentReactionsOptions = {} 16 | ) => { 17 | const api = useApi(); 18 | const queryClient = useQueryClient(); 19 | 20 | return useQuery( 21 | ['comment-reactions', { commentId, reactionType }], 22 | () => { 23 | return api.getCommentReactions({ 24 | comment_id: commentId, 25 | reaction_type: reactionType, 26 | }); 27 | }, 28 | { 29 | staleTime: Infinity, 30 | enabled: options.enabled, 31 | onSuccess: (data) => { 32 | data?.forEach((commentReaction) => { 33 | queryClient.setQueryData( 34 | ['users', commentReaction.user_id], 35 | commentReaction.user 36 | ); 37 | }); 38 | }, 39 | } 40 | ); 41 | }; 42 | 43 | export default useCommentReactions; 44 | -------------------------------------------------------------------------------- /src/hooks/useComments.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from 'react-query'; 2 | import { timeout } from '../utils'; 3 | import useApi from './useApi'; 4 | 5 | interface UseCommentsQuery { 6 | topic: string; 7 | parentId: string | null; 8 | } 9 | 10 | interface UseCommentsOptions { 11 | enabled?: boolean; 12 | } 13 | 14 | const useComments = ( 15 | { topic, parentId = null }: UseCommentsQuery, 16 | options: UseCommentsOptions = {} 17 | ) => { 18 | const api = useApi(); 19 | const queryClient = useQueryClient(); 20 | 21 | return useQuery( 22 | ['comments', { topic, parentId }], 23 | async () => { 24 | // This might look crazy, but it ensures the spinner will show for a 25 | // minimum of 200ms which is a pleasant amount of time for the sake of ux. 26 | const minTime = timeout(220); 27 | const comments = await api.getComments({ topic, parentId }); 28 | await minTime; 29 | return comments; 30 | }, 31 | { 32 | enabled: options.enabled, 33 | onSuccess: (data) => { 34 | data?.forEach((comment) => { 35 | queryClient.setQueryData(['comments', comment.id], comment); 36 | queryClient.setQueryData(['users', comment.user.id], comment.user); 37 | }); 38 | }, 39 | } 40 | ); 41 | }; 42 | 43 | export default useComments; 44 | -------------------------------------------------------------------------------- /src/hooks/useCssPalette.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react'; 2 | import Color from 'color'; 3 | 4 | const generatePalette = (baseColor: string) => { 5 | const color = Color(baseColor); 6 | const [h, s, l] = color.hsl().array(); 7 | 8 | return { 9 | 50: Color({ h, s, l: 95 }).string(), 10 | 100: Color({ h, s, l: 85 }).string(), 11 | 200: Color({ h, s, l: 75 }).string(), 12 | 300: Color({ h, s, l: 65 }).string(), 13 | 400: Color({ h, s, l: 55 }).string(), 14 | 500: Color({ h, s, l: 45 }).string(), 15 | 600: Color({ h, s, l: 35 }).string(), 16 | 700: Color({ h, s, l: 25 }).string(), 17 | 800: Color({ h, s, l: 15 }).string(), 18 | 900: Color({ h, s, l: 5 }).string(), 19 | }; 20 | }; 21 | 22 | const useCssPalette = (baseColor: string, variablePrefix: string) => { 23 | useLayoutEffect(() => { 24 | const palette = generatePalette(baseColor); 25 | Object.entries(palette).map(([key, val]) => { 26 | document.documentElement.style.setProperty( 27 | `--${variablePrefix}-${key}`, 28 | val 29 | ); 30 | }); 31 | return () => { 32 | Object.keys(palette).map((key) => { 33 | document.documentElement.style.removeProperty( 34 | `--${variablePrefix}-${key}` 35 | ); 36 | }); 37 | }; 38 | }, [baseColor, variablePrefix]); 39 | }; 40 | 41 | export default useCssPalette; 42 | -------------------------------------------------------------------------------- /src/hooks/useDeleteComment.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from 'react-query'; 2 | import useApi from './useApi'; 3 | 4 | interface UseDeleteCommentPayload { 5 | id: string; 6 | } 7 | 8 | const useDeleteComment = () => { 9 | const queryClient = useQueryClient(); 10 | const api = useApi(); 11 | 12 | return useMutation( 13 | ({ id }: UseDeleteCommentPayload) => { 14 | return api.deleteComment(id); 15 | }, 16 | { 17 | onSuccess: (data) => { 18 | queryClient.invalidateQueries([ 19 | 'comments', 20 | { topic: data.topic, parentId: data.parent_id }, 21 | ]); 22 | }, 23 | } 24 | ); 25 | }; 26 | 27 | export default useDeleteComment; 28 | -------------------------------------------------------------------------------- /src/hooks/useLatestRef.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | /** 4 | * React hook to persist any value between renders, 5 | * but keeps it up-to-date if it changes. 6 | * 7 | * @param value the value or function to persist 8 | */ 9 | export function useLatestRef(value: T) { 10 | const ref = React.useRef(null); 11 | ref.current = value; 12 | return ref as React.MutableRefObject; 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useReaction.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import useApi from './useApi'; 3 | 4 | interface UseReactionQuery { 5 | type: string; 6 | } 7 | 8 | interface UseReactionOptions { 9 | enabled?: boolean; 10 | } 11 | 12 | const useReaction = ( 13 | { type }: UseReactionQuery, 14 | options: UseReactionOptions = {} 15 | ) => { 16 | const api = useApi(); 17 | 18 | return useQuery( 19 | ['reactions', type], 20 | () => { 21 | return api.getReaction(type); 22 | }, 23 | { 24 | enabled: options.enabled, 25 | staleTime: Infinity, 26 | } 27 | ); 28 | }; 29 | 30 | export default useReaction; 31 | -------------------------------------------------------------------------------- /src/hooks/useReactions.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from 'react-query'; 2 | import useApi from './useApi'; 3 | 4 | interface UseReactionsOptions { 5 | enabled?: boolean; 6 | } 7 | const useReactions = (options: UseReactionsOptions = {}) => { 8 | const api = useApi(); 9 | const queryClient = useQueryClient(); 10 | 11 | return useQuery( 12 | ['reactions'], 13 | () => { 14 | return api.getReactions(); 15 | }, 16 | { 17 | enabled: options.enabled, 18 | staleTime: Infinity, 19 | onSuccess: (data) => { 20 | data?.forEach((reaction) => { 21 | queryClient.setQueryData(['reactions', reaction.type], reaction); 22 | }); 23 | }, 24 | } 25 | ); 26 | }; 27 | 28 | export default useReactions; 29 | -------------------------------------------------------------------------------- /src/hooks/useRemoveReaction.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from 'react-query'; 2 | import { Comment } from '../api'; 3 | import useApi from './useApi'; 4 | 5 | interface UseRemoveReactionPayload { 6 | reactionType: string; 7 | commentId: string; 8 | } 9 | 10 | // Do a little surgery on the comment and manually decrement the reaction count 11 | // or remove the item from the array if the reaction count was only 1 12 | const removeOrDecrement = (reactionType: string, comment: Comment): Comment => { 13 | let newArray = [...comment.reactions_metadata]; 14 | newArray = newArray.map((item) => { 15 | if (item.reaction_type === reactionType) { 16 | return { 17 | ...item, 18 | reaction_count: item.reaction_count - 1, 19 | active_for_user: false, 20 | }; 21 | } else { 22 | return item; 23 | } 24 | }); 25 | newArray = newArray.filter((item) => { 26 | return item.reaction_count > 0; 27 | }); 28 | newArray.sort((a, b) => a.reaction_type.localeCompare(b.reaction_type)); 29 | return { 30 | ...comment, 31 | reactions_metadata: newArray, 32 | }; 33 | }; 34 | 35 | const useRemoveReaction = () => { 36 | const api = useApi(); 37 | const queryClient = useQueryClient(); 38 | 39 | return useMutation( 40 | (payload: UseRemoveReactionPayload) => { 41 | return api.removeCommentReaction({ 42 | reaction_type: payload.reactionType, 43 | comment_id: payload.commentId, 44 | }); 45 | }, 46 | { 47 | onSuccess: (data, params) => { 48 | // Manually patch the comment while it refetches 49 | queryClient.setQueryData( 50 | ['comments', params.commentId], 51 | (prev: Comment) => removeOrDecrement(params.reactionType, prev) 52 | ); 53 | queryClient.invalidateQueries(['comments', params.commentId]); 54 | queryClient.invalidateQueries([ 55 | 'comment-reactions', 56 | { commentId: params.commentId, reactionType: params.reactionType }, 57 | ]); 58 | }, 59 | } 60 | ); 61 | }; 62 | 63 | export default useRemoveReaction; 64 | -------------------------------------------------------------------------------- /src/hooks/useSearchUsers.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from 'react-query'; 2 | import useApi from './useApi'; 3 | 4 | interface UseSearchUsersQuery { 5 | search: string; 6 | } 7 | 8 | interface UseSearchUsersOptions { 9 | enabled?: boolean; 10 | } 11 | 12 | const useSearchUsers = ( 13 | { search }: UseSearchUsersQuery, 14 | options: UseSearchUsersOptions = {} 15 | ) => { 16 | const api = useApi(); 17 | const queryClient = useQueryClient(); 18 | 19 | return useQuery( 20 | ['users', { search }], 21 | () => { 22 | return api.searchUsers(search); 23 | }, 24 | { 25 | enabled: options.enabled, 26 | staleTime: Infinity, 27 | onSuccess: (data) => { 28 | data?.forEach((user) => { 29 | queryClient.setQueryData(['users', user.id], user); 30 | }); 31 | }, 32 | } 33 | ); 34 | }; 35 | 36 | export default useSearchUsers; 37 | -------------------------------------------------------------------------------- /src/hooks/useUncontrolledState.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; 2 | 3 | interface UseUncontrolledStateOptions { 4 | defaultValue: T; 5 | } 6 | 7 | // Enables updates to default value when using uncontrolled inputs 8 | const useUncontrolledState = (options: UseUncontrolledStateOptions) => { 9 | const [state, setState] = useState({ 10 | defaultValue: options.defaultValue, 11 | value: options.defaultValue, 12 | key: 0, 13 | }); 14 | 15 | const setValue = useCallback( 16 | (val: T) => 17 | setState((prev) => ({ 18 | ...prev, 19 | value: val, 20 | })), 21 | [] 22 | ); 23 | 24 | const setDefaultValue = useCallback( 25 | (defaultVal: T) => 26 | setState((prev) => ({ 27 | key: prev.key + 1, 28 | value: defaultVal, 29 | defaultValue: defaultVal, 30 | })), 31 | [] 32 | ); 33 | 34 | const resetValue = useCallback( 35 | () => 36 | setState((prev) => ({ 37 | ...prev, 38 | value: prev.defaultValue, 39 | key: prev.key + 1, 40 | })), 41 | [] 42 | ); 43 | 44 | useLayoutEffect(() => { 45 | setDefaultValue(options.defaultValue); 46 | }, [options.defaultValue]); 47 | 48 | return useMemo( 49 | () => ({ 50 | ...state, 51 | setValue, 52 | setDefaultValue, 53 | resetValue, 54 | }), 55 | [state] 56 | ); 57 | }; 58 | 59 | export default useUncontrolledState; 60 | -------------------------------------------------------------------------------- /src/hooks/useUpdateComment.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from 'react-query'; 2 | import useApi from './useApi'; 3 | 4 | interface UseUpdateCommentPayload { 5 | id: string; 6 | comment: string; 7 | mentionedUserIds: string[]; 8 | } 9 | 10 | const useUpdateComment = () => { 11 | const api = useApi(); 12 | const queryClient = useQueryClient(); 13 | 14 | return useMutation( 15 | ({ id, comment, mentionedUserIds }: UseUpdateCommentPayload) => { 16 | return api.updateComment(id, { 17 | comment, 18 | mentioned_user_ids: mentionedUserIds, 19 | }); 20 | }, 21 | { 22 | onSuccess: (data) => { 23 | queryClient.invalidateQueries(['comments', data.id]); 24 | }, 25 | } 26 | ); 27 | }; 28 | 29 | export default useUpdateComment; 30 | -------------------------------------------------------------------------------- /src/hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import useApi from './useApi'; 3 | 4 | interface UseUserQuery { 5 | id: string; 6 | } 7 | 8 | interface UseUserOptions { 9 | enabled?: boolean; 10 | } 11 | 12 | const useUser = ({ id }: UseUserQuery, options: UseUserOptions = {}) => { 13 | const api = useApi(); 14 | 15 | return useQuery( 16 | ['users', id], 17 | () => { 18 | return api.getUser(id); 19 | }, 20 | { 21 | staleTime: Infinity, 22 | enabled: options.enabled, 23 | } 24 | ); 25 | }; 26 | 27 | export default useUser; 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './hooks'; 2 | export * from './components'; 3 | import './global.css'; 4 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default CSS definition for typescript, 3 | * will be overridden with file-specific definitions by rollup 4 | */ 5 | declare module '*.css' { 6 | const content: { [className: string]: string }; 7 | export default content; 8 | } 9 | 10 | interface SvgrComponent extends React.StatelessComponent> {} 11 | 12 | declare module '*.svg' { 13 | const svgUrl: string; 14 | const svgComponent: SvgrComponent; 15 | export default svgUrl; 16 | export { svgComponent as ReactComponent } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { generateJSON } from '@tiptap/html'; 2 | import Link from '@tiptap/extension-link'; 3 | import StarterKit from '@tiptap/starter-kit'; 4 | import Placeholder from '@tiptap/extension-placeholder'; 5 | import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 6 | import MentionsExtension from './components/Mentions'; 7 | import traverse from 'traverse'; 8 | 9 | export const getMentionedUserIds = (doc: string): string[] => { 10 | const userIds: string[] = []; 11 | const json = generateJSON(doc, [ 12 | StarterKit, 13 | Placeholder, 14 | MentionsExtension, 15 | CodeBlockLowlight, 16 | Link, 17 | ]); 18 | traverse(json).forEach(function (node) { 19 | if (node?.type === 'mention') { 20 | userIds.push(node.attrs.id); 21 | } 22 | }); 23 | return userIds; 24 | }; 25 | 26 | export const timeout = (ms: number) => 27 | new Promise((resolve) => setTimeout(resolve, ms)); 28 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | darkMode: 'class', 3 | content: ['./src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "target": "es6", 6 | "lib": ["es6", "dom", "es2016", "es2017"], 7 | "jsx": "react", 8 | "declaration": true, 9 | "declarationDir": "dist", 10 | "moduleResolution": "node", 11 | "noUnusedLocals": false, 12 | "noUnusedParameters": false, 13 | "esModuleInterop": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "allowSyntheticDefaultImports": true, 20 | "skipLibCheck": true, 21 | "resolveJsonModule": true 22 | }, 23 | "include": ["src", "bin", "bin"], 24 | "exclude": ["node_modules", "dist"] 25 | } 26 | --------------------------------------------------------------------------------