you wouldn't redesign a whole app in one commit
This commit is contained in:
		
							parent
							
								
									957a067568
								
							
						
					
					
						commit
						7669c5b4d6
					
				
					 69 changed files with 1232 additions and 506 deletions
				
			
		
							
								
								
									
										218
									
								
								src/App.svelte
									
										
									
									
									
								
							
							
						
						
									
										218
									
								
								src/App.svelte
									
										
									
									
									
								
							|  | @ -1,92 +1,109 @@ | |||
| <script> | ||||
|     import Feed from './Feed.svelte'; | ||||
|     import Navigation from './ui/Navigation.svelte'; | ||||
|     import Widgets from './ui/Widgets.svelte'; | ||||
|     import Feed from './ui/Feed.svelte'; | ||||
|     import { Client } from './client/client.js'; | ||||
|     import Button from './ui/Button.svelte'; | ||||
|     import { get } from 'svelte/store'; | ||||
| 
 | ||||
|     let ready = Client.get().app && Client.get().app.token; | ||||
|     let client = get(Client.get()); | ||||
|     let ready = client.app && client.app.token; | ||||
|     let instance_url_error = false; | ||||
|     let logging_in = false; | ||||
| 
 | ||||
|     let auth_code = new URLSearchParams(location.search).get("code"); | ||||
|     if (auth_code) { | ||||
|         let client = Client.get(); | ||||
|         client.getToken(auth_code).then(() => { | ||||
|             client.save(); | ||||
|             location = location.origin; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     if (client.app && client.app.token) { | ||||
|         client.verifyCredentials().then(res => { | ||||
|             if (res) { | ||||
|                 console.log(`Logged in as @${client.user.username}@${client.user.host}`); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     function log_in(event) { | ||||
|         let client = Client.get(); | ||||
|         logging_in = true; | ||||
|         event.preventDefault(); | ||||
|         const host = event.target.host.value; | ||||
| 
 | ||||
|         client.init(host).then(res => { | ||||
|             logging_in = false; | ||||
|             if (!res) return; | ||||
|             if (res.constructor === String) { | ||||
|                 instance_url_error = res; | ||||
|                 return; | ||||
|             }; | ||||
|             let oauth_url = client.getOAuthUrl(); | ||||
|             location = oauth_url; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     function log_out() { | ||||
|         Client.get().logout().then(() => { | ||||
|             ready = false; | ||||
|         }); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <header> | ||||
|     <h1>space social</h1> | ||||
|     <p>social media for the galaxy-wide-web! 🌌</p> | ||||
|     <button id="logout" on:click={log_out}>log out</button> | ||||
| </header> | ||||
| <div id="spacesocial-app"> | ||||
| 
 | ||||
| <main> | ||||
|     {#if ready} | ||||
|         <Feed /> | ||||
|     {:else} | ||||
|         <div class="pane"> | ||||
|             <form on:submit={log_in} id="login"> | ||||
|                 <h1>welcome!</h1> | ||||
|                 <p>please enter your instance domain to log in.</p> | ||||
|                 <input type="text" id="host" aria-label="instance domain"> | ||||
|                 <button type="submit" id="login">log in</button> | ||||
|             </form> | ||||
|     <header> | ||||
|         <Navigation /> | ||||
|     </header> | ||||
| 
 | ||||
|             <hr> | ||||
|     <main> | ||||
|         {#if ready} | ||||
|             <Feed /> | ||||
|         {:else} | ||||
|             <div> | ||||
|                 <form on:submit={log_in} id="login"> | ||||
|                     <h1>Space Social</h1> | ||||
|                     <p>Welcome, fediverse user!</p> | ||||
|                     <p>Please enter your instance domain to log in.</p> | ||||
|                     <div class="input-wrapper"> | ||||
|                         <input type="text" id="host" aria-label="instance domain" class={logging_in ? "throb" : ""}> | ||||
|                         {#if instance_url_error} | ||||
|                             <p class="error">{instance_url_error}</p> | ||||
|                         {/if} | ||||
|                     </div> | ||||
|                     <br> | ||||
|                     <button type="submit" id="login" class={logging_in ? "disabled" : ""}>Log in</button> | ||||
|                     <p><small> | ||||
|                         Please note this is | ||||
|                         <strong><em>extremely experimental software</em></strong>; | ||||
|                         things are likely to break! | ||||
|                         <br> | ||||
|                         If that's all cool with you, welcome aboard! | ||||
|                     </small></p> | ||||
| 
 | ||||
|             <p><small> | ||||
|                 please note this is <strong><em>extremely experimental software</em></strong>; | ||||
|                 even if you use the exact same instance as me, you may encounter problems. | ||||
|                 if that's all cool with you, welcome aboard! | ||||
|             </small></p> | ||||
|                     <p class="form-footer">made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p> | ||||
|                 </form> | ||||
|             </div> | ||||
|         {/if} | ||||
|     </main> | ||||
| 
 | ||||
|             <p>made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p> | ||||
|         </div> | ||||
|     {/if} | ||||
| </main> | ||||
|     <div id="widgets"> | ||||
|         <Widgets /> | ||||
|     </div> | ||||
| 
 | ||||
| <footer> | ||||
| </footer> | ||||
| </div> | ||||
| 
 | ||||
| <style> | ||||
|     header { | ||||
|         width: min(768px, calc(100vw - 32px)); | ||||
|         margin: 16px auto; | ||||
|     #spacesocial-app { | ||||
|         margin: auto 0; | ||||
|         padding: 0 16px; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         gap: 16px; | ||||
|     } | ||||
| 
 | ||||
|     header h1 { | ||||
|         margin: 0 16px 0 0; | ||||
|     } | ||||
| 
 | ||||
|     h1 { | ||||
|         color: var(--accent); | ||||
|     header, #widgets { | ||||
|         width: 300px; | ||||
|     } | ||||
| 
 | ||||
|     main { | ||||
|         width: min(800px, calc(100vw - 16px)); | ||||
|         margin: 0 auto; | ||||
|         width: 732px; | ||||
|     } | ||||
| 
 | ||||
|     div.pane { | ||||
|  | @ -98,7 +115,7 @@ | |||
|     } | ||||
| 
 | ||||
|     form#login { | ||||
|         margin: 64px 0; | ||||
|         margin: 25vh 0 32px 0; | ||||
|         text-align: center; | ||||
|     } | ||||
| 
 | ||||
|  | @ -111,32 +128,93 @@ | |||
|         text-decoration: underline; | ||||
|     } | ||||
| 
 | ||||
|     input[type="text"] { | ||||
|         margin: 8px 0; | ||||
|         padding: 4px 6px; | ||||
|         font-family: inherit; | ||||
|         border: none; | ||||
|     .input-wrapper { | ||||
|         width: 360px; | ||||
|         margin: 0 auto; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         align-items: center; | ||||
|     } | ||||
| 
 | ||||
|     input[type=text] { | ||||
|         width: 100%; | ||||
|         padding: 12px; | ||||
|         display: block; | ||||
|         border-radius: 8px; | ||||
|         border: 1px solid var(--accent); | ||||
|         background-color: var(--bg-800); | ||||
| 
 | ||||
|         font-family: inherit; | ||||
|         font-weight: bold; | ||||
|         font-size: inherit; | ||||
|         color: var(--text); | ||||
| 
 | ||||
|         transition: box-shadow .2s; | ||||
|     } | ||||
| 
 | ||||
|     button#login, button#logout { | ||||
|         margin-left: auto; | ||||
|         padding: 8px 12px; | ||||
|         font-size: 1em; | ||||
|         background-color: var(--bg2); | ||||
|         color: inherit; | ||||
|         border: none; | ||||
|         border-radius: 16px; | ||||
|     input[type=text]::placeholder { | ||||
|         opacity: .8; | ||||
|     } | ||||
| 
 | ||||
|     input[type=text]:focus { | ||||
|         outline: none; | ||||
|         box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 25%); | ||||
|     } | ||||
| 
 | ||||
|     .error { | ||||
|         margin: 6px; | ||||
|         font-style: italic; | ||||
|         font-size: .9em; | ||||
|         color: red; | ||||
|         opacity: .7; | ||||
|     } | ||||
| 
 | ||||
|     button#login { | ||||
|         margin: -8px auto 0 auto; | ||||
|         padding: 12px 24px; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|         justify-content: space-between; | ||||
|         align-items: center; | ||||
| 
 | ||||
|         font-family: inherit; | ||||
|         font-size: 1rem; | ||||
|         font-weight: 600; | ||||
|         text-align: left; | ||||
| 
 | ||||
|         border-radius: 8px; | ||||
|         border-width: 2px; | ||||
|         border-style: solid; | ||||
| 
 | ||||
|         background-color: var(--bg-700); | ||||
|         color: var(--text); | ||||
|         border-color: transparent; | ||||
| 
 | ||||
|         transition-property: border-color, background-color, color; | ||||
|         transition-timing-function: ease-out; | ||||
|         transition-duration: .1s; | ||||
| 
 | ||||
|         cursor: pointer; | ||||
|         transition: color .1s, background-color .1s; | ||||
|         text-align: center; | ||||
|         justify-content: center; | ||||
|     } | ||||
| 
 | ||||
|     button#login:hover, button#logout:hover { | ||||
|         color: var(--bg0); | ||||
|         background: var(--fg0); | ||||
|     button#login:hover { | ||||
|         background-color: color-mix(in srgb, var(--bg-700), var(--accent) 10%); | ||||
|         border-color: color-mix(in srgb, var(--bg-700), var(--accent) 20%); | ||||
|     } | ||||
| 
 | ||||
|     button#login:active, button#logout:active { | ||||
|         background: #0001; | ||||
|     button#login:active { | ||||
|         background-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 50%); | ||||
|         border-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 10%); | ||||
|     } | ||||
| 
 | ||||
|     button#login.disabled { | ||||
|         opacity: .5; | ||||
|         cursor: initial; | ||||
|     } | ||||
| 
 | ||||
|     .form-footer { | ||||
|         opacity: .7; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
							
								
								
									
										101
									
								
								src/Feed.svelte
									
										
									
									
									
								
							
							
						
						
									
										101
									
								
								src/Feed.svelte
									
										
									
									
									
								
							|  | @ -1,101 +0,0 @@ | |||
| <script> | ||||
|     import Post from './post/Post.svelte'; | ||||
|     import Error from './Error.svelte'; | ||||
|     import { Client } from './client/client.js'; | ||||
|     import { parsePost } from './client/api.js'; | ||||
| 
 | ||||
|     let client = Client.get(); | ||||
|     let posts = []; | ||||
|     let loading = false; | ||||
| 
 | ||||
|     let error; | ||||
| 
 | ||||
|     async function load_posts() { | ||||
|         if (loading) return; // no spamming!! | ||||
|         loading = true; | ||||
| 
 | ||||
|         let timeline_data; | ||||
|         if (posts.length === 0) timeline_data = await client.getTimeline() | ||||
|         else timeline_data = await client.getTimeline(posts[posts.length - 1].id); | ||||
| 
 | ||||
|         if (!timeline_data) { | ||||
|             console.error(`Failed to retrieve timeline.`); | ||||
|             loading = false; | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         for (let i in timeline_data) { | ||||
|             const post_data = timeline_data[i]; | ||||
|             const post = await parsePost(post_data, 1); | ||||
|             if (!post) { | ||||
|                 if (post === null || post === undefined) { | ||||
|                     if (post_data.id) { | ||||
|                         console.warn("Failed to parse post #" + post_data.id); | ||||
|                     } else { | ||||
|                         console.warn("Failed to parse post:"); | ||||
|                         console.warn(post_data); | ||||
|                     } | ||||
|                 } | ||||
|                 continue; | ||||
|             } | ||||
|             posts = [...posts, post]; | ||||
|         } | ||||
|         loading = false; | ||||
|     } | ||||
| 
 | ||||
|     load_posts(); | ||||
|     document.addEventListener("scroll", event => { | ||||
|         if (!loading && window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) { | ||||
|             load_posts(); | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     /* | ||||
|     client.getPost("", 1).then(post => { | ||||
|         posts = [...posts, post]; | ||||
|         console.log(post); | ||||
|     }); | ||||
|     */ | ||||
| </script> | ||||
| 
 | ||||
| <div id="feed"> | ||||
|     {#if error} | ||||
|         <Error msg={error.replaceAll('\n', '<br>')} /> | ||||
|     {/if} | ||||
|     {#if posts.length <= 0} | ||||
|         <div class="loading"> | ||||
|             <span>just a moment...</span> | ||||
|         </div> | ||||
|     {/if} | ||||
|     {#each posts as post} | ||||
|         <Post post_data={post} /> | ||||
|     {/each} | ||||
| </div> | ||||
| 
 | ||||
| <style> | ||||
|     .loading { | ||||
|         width: 100%; | ||||
|         height: 80vh; | ||||
|         display: flex; | ||||
|         justify-content: center; | ||||
|         align-items: center; | ||||
|         font-size: 2em; | ||||
|         font-weight: bold; | ||||
|     } | ||||
| 
 | ||||
|     .loading span { | ||||
|         animation: pulse 1s infinite; | ||||
|     } | ||||
| 
 | ||||
|     @keyframes pulse { | ||||
|         from { | ||||
|             opacity: .5; | ||||
|         } | ||||
|         50% { | ||||
|             opacity: 1; | ||||
|         } | ||||
|         to { | ||||
|             opacity: .5; | ||||
|         } | ||||
|     } | ||||
| </style> | ||||
							
								
								
									
										50
									
								
								src/app.css
									
										
									
									
									
								
							
							
						
						
									
										50
									
								
								src/app.css
									
										
									
									
									
								
							|  | @ -1,20 +1,54 @@ | |||
| @import url("/font/inter/inter.css"); | ||||
| 
 | ||||
| :root { | ||||
|     --fg0: #eee; | ||||
|     --bg0: #080808; | ||||
|     --bg1: #101010; | ||||
|     --bg2: #121212; | ||||
|     --accent: #b7fd49; | ||||
|     --accent-bg: #242b1a; | ||||
|     --bg-1000: #fff6de; | ||||
|     --bg-900: #f9f1db; | ||||
|     --bg-800: #f1e8cf; | ||||
|     --bg-700: #d2c9b1; | ||||
|     --bg-600: #f0f6c2; | ||||
|     --accent: #8d9936; | ||||
|     --text: #322e1f; | ||||
| } | ||||
| 
 | ||||
| @media (prefers-color-scheme: dark) { | ||||
|     :root { | ||||
|         --bg-1000: #141016; | ||||
|         --bg-900: #1B141E; | ||||
|         --bg-800: #2A202F; | ||||
|         --bg-700: #443749; | ||||
|         --bg-600: #513D60; | ||||
|         --accent: #CDA1EC; | ||||
|         --text: #E2DFE3; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @supports (font-variation-settings: normal) { | ||||
|     body { font-family: InterVariable, sans-serif; } | ||||
| } | ||||
| 
 | ||||
| body { | ||||
|     width: 100vw; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
| 
 | ||||
|     color: var(--fg0); | ||||
|     background-color: var(--bg0); | ||||
|     color: var(--text); | ||||
|     background-color: var(--bg-1000); | ||||
| 
 | ||||
|     font-family: "Inter", sans-serif; | ||||
|     font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */ | ||||
| 
 | ||||
|     box-sizing: border-box; | ||||
| } | ||||
| 
 | ||||
| .throb { | ||||
|     animation: .25s throb alternate infinite ease-in; | ||||
| } | ||||
| 
 | ||||
| @keyframes throb { | ||||
|     from { | ||||
|         opacity: .5; | ||||
|     } | ||||
|     to { | ||||
|         opacity: 1; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +1,9 @@ | |||
| import { Client } from '../client/client.js'; | ||||
| import { capabilities } from '../client/instance.js'; | ||||
| import Post from '../post/post.js'; | ||||
| import Post from '../post.js'; | ||||
| import User from '../user/user.js'; | ||||
| import Emoji from '../emoji.js'; | ||||
| import { get } from 'svelte/store'; | ||||
| 
 | ||||
| export async function createApp(host) { | ||||
|     let form = new FormData(); | ||||
|  | @ -30,7 +31,7 @@ export async function createApp(host) { | |||
| } | ||||
| 
 | ||||
| export function getOAuthUrl() { | ||||
|     let client = Client.get(); | ||||
|     let client = get(Client.get()); | ||||
|     return `https://${client.instance.host}/oauth/authorize` + | ||||
|         `?client_id=${client.app.id}` + | ||||
|         "&scope=read+write+push" + | ||||
|  | @ -39,7 +40,7 @@ export function getOAuthUrl() { | |||
| } | ||||
| 
 | ||||
| export async function getToken(code) { | ||||
|     let client = Client.get(); | ||||
|     let client = get(Client.get()); | ||||
|     let form = new FormData(); | ||||
|     form.append("client_id", client.app.id); | ||||
|     form.append("client_secret", client.app.secret); | ||||
|  | @ -64,7 +65,7 @@ export async function getToken(code) { | |||
| } | ||||
| 
 | ||||
| export async function revokeToken() { | ||||
|     let client = Client.get(); | ||||
|     let client = get(Client.get()); | ||||
|     let form = new FormData(); | ||||
|     form.append("client_id", client.app.id); | ||||
|     form.append("client_secret", client.app.secret); | ||||
|  | @ -83,8 +84,19 @@ export async function revokeToken() { | |||
|     return true; | ||||
| } | ||||
| 
 | ||||
| export async function verifyCredentials() { | ||||
|     let client = get(Client.get()); | ||||
|     let url = `https://${client.instance.host}/api/v1/accounts/verify_credentials`; | ||||
|     const data = await fetch(url, { | ||||
|         method: 'GET', | ||||
|         headers: { "Authorization": "Bearer " + client.app.token } | ||||
|     }).then(res => res.json()); | ||||
| 
 | ||||
|     return data; | ||||
| } | ||||
| 
 | ||||
| export async function getTimeline(last_post_id) { | ||||
|     let client = Client.get(); | ||||
|     let client = get(Client.get()); | ||||
|     let url = `https://${client.instance.host}/api/v1/timelines/home`; | ||||
|     if (last_post_id) url += "?max_id=" + last_post_id; | ||||
|     const data = await fetch(url, { | ||||
|  | @ -95,8 +107,8 @@ export async function getTimeline(last_post_id) { | |||
|     return data; | ||||
| } | ||||
| 
 | ||||
| export async function getPost(post_id, num_replies) { | ||||
|     let client = Client.get(); | ||||
| export async function getPost(post_id, parent_replies) { | ||||
|     let client = get(Client.get()); | ||||
|     let url = `https://${client.instance.host}/api/v1/statuses/${post_id}`; | ||||
|     const data = await fetch(url, { | ||||
|         method: 'GET', | ||||
|  | @ -104,23 +116,49 @@ export async function getPost(post_id, num_replies) { | |||
|     }).then(res => { return res.ok ? res.json() : false }); | ||||
| 
 | ||||
|     if (data === false) return false; | ||||
| 
 | ||||
|     const post = await parsePost(data, num_replies); | ||||
|     if (post === null || post === undefined) { | ||||
|         if (data.id) { | ||||
|             console.warn("Failed to parse post data #" + data.id); | ||||
|         } else { | ||||
|             console.warn("Failed to parse post data:"); | ||||
|             console.warn(data); | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|     return post; | ||||
|     return data; | ||||
| } | ||||
| 
 | ||||
| export async function parsePost(data, num_replies) { | ||||
|     let client = Client.get(); | ||||
|     let post = new Post() | ||||
| export async function getPostContext(post_id) { | ||||
|     let client = get(Client.get()); | ||||
|     let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/context`; | ||||
|     const data = await fetch(url, { | ||||
|         method: 'GET', | ||||
|         headers: { "Authorization": "Bearer " + client.app.token } | ||||
|     }).then(res => { return res.ok ? res.json() : false }); | ||||
| 
 | ||||
|     if (data === false) return false; | ||||
|     return data; | ||||
| } | ||||
| 
 | ||||
| export async function parsePost(data, parent_replies, child_replies) { | ||||
|     let client = get(Client.get()); | ||||
|     let post = new Post(); | ||||
| 
 | ||||
|     // if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT))
 | ||||
|     //     post.text = data.text;
 | ||||
|     // else
 | ||||
|     post.text = data.content; | ||||
| 
 | ||||
|     post.reply = null; | ||||
|     if ((data.in_reply_to_id || data.reply) && parent_replies !== 0) { | ||||
|         const reply_data = data.reply || await getPost(data.in_reply_to_id, parent_replies - 1); | ||||
|         post.reply = await parsePost(reply_data, parent_replies - 1, false); | ||||
|         // if the post returns false, we probably don't have permission to read it.
 | ||||
|         // we'll respect the thread's privacy, and leave it alone :)
 | ||||
|         if (post.reply === false) return false; | ||||
|     } | ||||
|     post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null; | ||||
| 
 | ||||
|     post.replies = []; | ||||
|     if (child_replies) { | ||||
|         const replies_data = await getPostContext(data.id); | ||||
|         if (replies_data && replies_data.descendants) { | ||||
|             for (let i in replies_data.descendants) { | ||||
|                 post.replies.push(await parsePost(replies_data.descendants[i], 0, false)); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     post.id = data.id; | ||||
|     post.created_at = new Date(data.created_at); | ||||
|  | @ -133,32 +171,20 @@ export async function parsePost(data, num_replies) { | |||
|     post.url = data.url; | ||||
|     post.visibility = data.visibility; | ||||
| 
 | ||||
|     if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT)) | ||||
|         post.text = data.text; | ||||
|     else | ||||
|         post.text = data.content; | ||||
| 
 | ||||
|     post.reply = null; | ||||
|     if (data.in_reply_to_id && num_replies > 0) { | ||||
|         post.reply = await getPost(data.in_reply_to_id, num_replies - 1); | ||||
|         // if the post returns false, we probably don't have permission to read it.
 | ||||
|         // we'll respect the thread's privacy, and leave it alone :)
 | ||||
|         if (post.reply === false) return false; | ||||
|     } | ||||
|     post.boost = data.reblog ? await parsePost(data.reblog, 1) : null; | ||||
| 
 | ||||
|     post.emojis = []; | ||||
|     data.emojis.forEach(emoji_data => { | ||||
|         let name = emoji_data.shortcode.split('@')[0]; | ||||
|         post.emojis.push(parseEmoji({ | ||||
|             id: name + '@' + post.user.host, | ||||
|             name: name, | ||||
|             host: post.user.host, | ||||
|             url: emoji_data.url, | ||||
|         })); | ||||
|     }); | ||||
|     if (data.emojis) { | ||||
|         data.emojis.forEach(emoji_data => { | ||||
|             let name = emoji_data.shortcode.split('@')[0]; | ||||
|             post.emojis.push(parseEmoji({ | ||||
|                 id: name + '@' + post.user.host, | ||||
|                 name: name, | ||||
|                 host: post.user.host, | ||||
|                 url: emoji_data.url, | ||||
|             })); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     if (client.instance.capabilities.includes(capabilities.REACTIONS)) { | ||||
|     if (data.reactions && client.instance.capabilities.includes(capabilities.REACTIONS)) { | ||||
|         post.reactions = []; | ||||
|         data.reactions.forEach(reaction_data => { | ||||
|             if (/^[\w\-.@]+$/g.exec(reaction_data.name)) { | ||||
|  | @ -191,7 +217,17 @@ export async function parsePost(data, num_replies) { | |||
| } | ||||
| 
 | ||||
| export async function parseUser(data) { | ||||
|     let user = new User(); | ||||
|     if (!data) { | ||||
|         console.error("Attempted to parse user data but no data was provided"); | ||||
|         return null; | ||||
|     } | ||||
|     let client = get(Client.get()); | ||||
|     let user = await client.getCacheUser(data.id); | ||||
| 
 | ||||
|     if (user) return user; | ||||
|     // cache miss!
 | ||||
| 
 | ||||
|     user = new User(); | ||||
|     user.id = data.id; | ||||
|     user.nickname = data.display_name; | ||||
|     user.username = data.username; | ||||
|  | @ -201,7 +237,7 @@ export async function parseUser(data) { | |||
|     if (data.acct.includes('@')) | ||||
|         user.host = data.acct.split('@')[1]; | ||||
|     else | ||||
|         user.host = Client.get().instance.host; | ||||
|         user.host = get(Client.get()).instance.host; | ||||
| 
 | ||||
|     user.emojis = []; | ||||
|     data.emojis.forEach(emoji_data => { | ||||
|  | @ -211,7 +247,7 @@ export async function parseUser(data) { | |||
|         user.emojis.push(parseEmoji(emoji_data)); | ||||
|     }); | ||||
| 
 | ||||
|     Client.get().putCacheUser(user); | ||||
|     get(Client.get()).putCacheUser(user); | ||||
|     return user; | ||||
| } | ||||
| 
 | ||||
|  | @ -222,12 +258,12 @@ export function parseEmoji(data) { | |||
|         data.host, | ||||
|         data.url, | ||||
|     ); | ||||
|     Client.get().putCacheEmoji(emoji); | ||||
|     get(Client.get()).putCacheEmoji(emoji); | ||||
|     return emoji; | ||||
| } | ||||
| 
 | ||||
| export async function getUser(user_id) { | ||||
|     let client = Client.get(); | ||||
|     let client = get(Client.get()); | ||||
|     let url = `https://${client.instance.host}/api/v1/accounts/${user_id}`; | ||||
|     const data = await fetch(url, { | ||||
|         method: 'GET', | ||||
|  |  | |||
|  | @ -1,14 +1,16 @@ | |||
| import { version as APP_VERSION } from '../../package.json'; | ||||
| import { Instance, server_types } from './instance.js'; | ||||
| import * as api from './api.js'; | ||||
| import { get, writable } from 'svelte/store'; | ||||
| 
 | ||||
| let client = false; | ||||
| let client = writable(false); | ||||
| 
 | ||||
| const save_name = "spacesocial"; | ||||
| 
 | ||||
| export class Client { | ||||
|     instance; | ||||
|     app; | ||||
|     user; | ||||
|     #cache; | ||||
| 
 | ||||
|     constructor() { | ||||
|  | @ -21,10 +23,11 @@ export class Client { | |||
|     } | ||||
| 
 | ||||
|     static get() { | ||||
|         if (client) return client; | ||||
|         client = new Client(); | ||||
|         window.peekie = client; | ||||
|         client.load(); | ||||
|         if (get(client)) return client; | ||||
|         let new_client = new Client(); | ||||
|         window.peekie = new_client; | ||||
|         new_client.load(); | ||||
|         client.set(new_client); | ||||
|         return client; | ||||
|     } | ||||
| 
 | ||||
|  | @ -34,8 +37,7 @@ export class Client { | |||
|         const data = await fetch(url).then(res => res.json()).catch(error => { console.error(error) }); | ||||
|         if (!data) { | ||||
|             console.error(`Failed to connect to ${host}`); | ||||
|             alert(`Failed to connect to ${host}! Please try again later.`); | ||||
|             return false; | ||||
|             return `Failed to connect to ${host}!`; | ||||
|         } | ||||
|          | ||||
|         this.instance = new Instance(host, data.version); | ||||
|  | @ -59,6 +61,8 @@ export class Client { | |||
| 
 | ||||
|         this.save(); | ||||
| 
 | ||||
|         client.set(this); | ||||
| 
 | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|  | @ -73,31 +77,38 @@ export class Client { | |||
|             return false; | ||||
|         } | ||||
|         this.app.token = token; | ||||
|         client.set(this); | ||||
|     } | ||||
| 
 | ||||
|     async revokeToken() { | ||||
|         return await api.revokeToken(); | ||||
|     } | ||||
| 
 | ||||
|     async verifyCredentials() { | ||||
|         const data = await api.verifyCredentials(); | ||||
|         if (!data) return false; | ||||
|         this.user = await api.parseUser(data); | ||||
|         client.set(this); | ||||
|         return data; | ||||
|     } | ||||
| 
 | ||||
|     async getTimeline(last_post_id) { | ||||
|         return await api.getTimeline(last_post_id); | ||||
|     } | ||||
| 
 | ||||
|     async getPost(post_id, num_replies) { | ||||
|         return await api.getPost(post_id, num_replies); | ||||
|     async getPost(post_id, parent_replies, child_replies) { | ||||
|         return await api.getPost(post_id, parent_replies, child_replies); | ||||
|     } | ||||
| 
 | ||||
|     putCacheUser(user) { | ||||
|         this.cache.users[user.id] = user; | ||||
|         client.set(this); | ||||
|     } | ||||
| 
 | ||||
|     async getUser(user_id) { | ||||
|     async getCacheUser(user_id) { | ||||
|         let user = this.cache.users[user_id]; | ||||
|         if (user) return user; | ||||
| 
 | ||||
|         user = await api.getUser(user_id); | ||||
|         if (user) return user; | ||||
| 
 | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|  | @ -112,6 +123,7 @@ export class Client { | |||
| 
 | ||||
|     putCacheEmoji(emoji) { | ||||
|         this.cache.emojis[emoji.id] = emoji; | ||||
|         client.set(this); | ||||
|     } | ||||
| 
 | ||||
|     getEmoji(emoji_id) { | ||||
|  | @ -142,6 +154,7 @@ export class Client { | |||
|         } | ||||
|         this.instance = new Instance(saved.instance.host, saved.instance.version); | ||||
|         this.app = saved.app; | ||||
|         client.set(this); | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|  | @ -151,7 +164,7 @@ export class Client { | |||
|             console.warn("Failed to log out correctly; ditching the old tokens anyways."); | ||||
|         } | ||||
|         localStorage.removeItem(save_name); | ||||
|         client = new Client(); | ||||
|         client.set(false); | ||||
|         console.log("Logged out successfully."); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| import { Client } from './client/client.js'; | ||||
| import { get } from 'svelte/store'; | ||||
| 
 | ||||
| export const EMOJI_REGEX = /:[\w\-.]{0,32}@[\w\-.]{0,32}:/g; | ||||
| export const EMOJI_NAME_REGEX = /:[\w\-.]{0,32}:/g; | ||||
|  | @ -31,7 +32,7 @@ export function parseText(text, host) { | |||
|     let length = text.substring(index + 1).search(':'); | ||||
|     if (length <= 0) return text; | ||||
|     let emoji_name = text.substring(index + 1, index + length + 1); | ||||
|     let emoji = Client.get().getEmoji(emoji_name + '@' + host); | ||||
|     let emoji = get(Client.get()).getEmoji(emoji_name + '@' + host); | ||||
| 
 | ||||
|     if (emoji) { | ||||
|         return text.substring(0, index) + emoji.html + | ||||
|  | @ -44,7 +45,7 @@ export function parseText(text, host) { | |||
| export function parseOne(emoji_id) { | ||||
|     if (emoji_id == '❤') return '❤️'; // stupid heart unicode
 | ||||
|     if (EMOJI_REGEX.exec(':' + emoji_id + ':')) return emoji_id; | ||||
|     let cached_emoji = Client.get().getEmoji(emoji_id); | ||||
|     let cached_emoji = get(Client.get()).getEmoji(emoji_id); | ||||
|     if (!cached_emoji) return emoji_id; | ||||
|     return cached_emoji.html; | ||||
| } | ||||
|  |  | |||
							
								
								
									
										29
									
								
								src/img/spacesocial-logo.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/img/spacesocial-logo.svg
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> | ||||
| <svg width="100%" height="100%" viewBox="0 0 226 89" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> | ||||
|     <g transform="matrix(0.0592476,0,0,0.0592476,19.3835,-44.4646)"> | ||||
|         <clipPath id="_clip1"> | ||||
|             <path d="M3471.16,750.487L3471.16,2249.51L-327.161,2249.51L-327.161,750.487L3471.16,750.487ZM2317.49,1260.74L1795.39,1260.74L1795.39,1763.02L2317.49,1763.02L2317.49,1260.74Z"/> | ||||
|         </clipPath> | ||||
|         <g clip-path="url(#_clip1)"> | ||||
|             <g transform="matrix(3.12421,0,0,3.12421,13.4825,-77.5092)"> | ||||
|                 <g id="spinner"> | ||||
|                     <path d="M380,380C476.218,283.782 659.849,234.849 710,285C760.151,335.151 756.844,478.156 634,601C511.156,723.844 358.099,784.099 291,717C249.901,675.901 257.955,619 257.955,619C260.181,637.245 251.818,720.443 352.404,720.443C452.989,720.443 530.426,645.937 610.046,566.318C689.665,486.699 778.651,275.064 635.273,275.064C491.896,275.064 380,380 380,380Z"/> | ||||
|                 </g> | ||||
|             </g> | ||||
|             <g transform="matrix(44.394,0.545455,-44.394,0.545455,899.136,728.049)"> | ||||
|                 <g id="star-shine" serif:id="star shine"> | ||||
|                     <rect x="383" y="377" width="11" height="11"/> | ||||
|                 </g> | ||||
|             </g> | ||||
|             <g id="star" transform="matrix(1.45161,0,0,1.45161,531.871,522.581)"> | ||||
|                 <path d="M436.5,384L449.059,417.941L483,430.5L449.059,443.059L436.5,477L423.941,443.059L390,430.5L423.941,417.941L436.5,384Z"/> | ||||
|             </g> | ||||
|         </g> | ||||
|     </g> | ||||
|     <g transform="matrix(2.67689,0,0,2.67689,-1226.58,-333.741)"> | ||||
|         <g transform="matrix(13.6363,0,0,13.6363,542.101,145.423)"> | ||||
|         </g> | ||||
|         <text x="457.966px" y="145.423px" style="font-family:'Inter-BoldItalic', 'Inter';font-weight:700;font-style:italic;font-size:13.636px;">space social</text> | ||||
|     </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 2.1 KiB | 
|  | @ -1,4 +1,4 @@ | |||
| import { parseText as parseEmoji } from '../emoji.js'; | ||||
| import { parseText as parseEmoji } from './emoji.js'; | ||||
| 
 | ||||
| export default class Post { | ||||
|     id; | ||||
|  | @ -14,6 +14,7 @@ export default class Post { | |||
|     files; | ||||
|     url; | ||||
|     reply; | ||||
|     replies; | ||||
|     boost; | ||||
|     visibility; | ||||
| 
 | ||||
|  | @ -1,85 +0,0 @@ | |||
| <script> | ||||
|     import BoostContext from './BoostContext.svelte'; | ||||
|     import ReplyContext from './ReplyContext.svelte'; | ||||
|     import Header from './Header.svelte'; | ||||
|     import Body from './Body.svelte'; | ||||
|     import FooterButton from './FooterButton.svelte'; | ||||
|     import { parseOne as parseEmoji } from '../emoji.js'; | ||||
|     import { play_sound } from '../sound.js'; | ||||
| 
 | ||||
|     export let post_data; | ||||
| 
 | ||||
|     let post_context = undefined; | ||||
|     let post = post_data; | ||||
|     let is_boost = false; | ||||
|     if (post_data.boost) { | ||||
|         is_boost = true; | ||||
|         post_context = post_data; | ||||
|         post = post_data.boost; | ||||
|     } | ||||
| 
 | ||||
|     let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at; | ||||
| </script> | ||||
| 
 | ||||
| <div class="post-container" aria-label={aria_label}> | ||||
|     {#if post.reply} | ||||
|         <ReplyContext post={post.reply} /> | ||||
|     {/if} | ||||
|     {#if is_boost && !post_context.text} | ||||
|         <BoostContext post={post_context} /> | ||||
|     {/if} | ||||
|     <article class="post"> | ||||
|         <Header post={post} /> | ||||
|         <Body post={post} /> | ||||
|         <footer class="post-footer"> | ||||
|             <div class="post-reactions"> | ||||
|                 {#each post.reactions as reaction} | ||||
|                     <FooterButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" /> | ||||
|                 {/each} | ||||
|             </div> | ||||
|             <div class="post-actions"> | ||||
|                 <FooterButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} sound="post" /> | ||||
|                 <FooterButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} sound="boost" /> | ||||
|                 <FooterButton icon="⭐" type="favourite" label="Favourite" /> | ||||
|                 <FooterButton icon="😃" type="react" label="React" /> | ||||
|                 <FooterButton icon="🗣️" type="quote" label="Quote" /> | ||||
|                 <FooterButton icon="🛠️" type="more" label="More" /> | ||||
|             </div> | ||||
|         </footer> | ||||
|     </article> | ||||
| </div> | ||||
| 
 | ||||
| <style> | ||||
|     .post-container { | ||||
|         margin-top: 16px; | ||||
|         padding: 28px 32px 20px 32px; | ||||
|         border: 1px solid #8884; | ||||
|         border-radius: 16px; | ||||
|         background-color: var(--bg1); | ||||
|         transition: background-color .1s; | ||||
|     } | ||||
| 
 | ||||
|     .post-container:hover { | ||||
|         background-color: var(--bg2); | ||||
|     } | ||||
| 
 | ||||
|     .post-container:hover :global(.post-context) { | ||||
|         opacity: 1; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     :global(.post-reactions) { | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|     } | ||||
| 
 | ||||
|     :global(.post-actions) { | ||||
|         margin-top: 8px; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|     } | ||||
| 
 | ||||
|     .post-container :global(.emoji) { | ||||
|         height: 20px; | ||||
|     } | ||||
| </style> | ||||
|  | @ -1,153 +0,0 @@ | |||
| <script> | ||||
|     import Header from './Header.svelte'; | ||||
|     import Body from './Body.svelte'; | ||||
|     import FooterButton from './FooterButton.svelte'; | ||||
|     import Post from './Post.svelte'; | ||||
|     import { parseText as parseEmojis, parseOne as parseEmoji } from '../emoji.js'; | ||||
|     import { shorthand as short_time } from '../time.js'; | ||||
| 
 | ||||
|     export let post; | ||||
| 
 | ||||
|     let time_string = post.created_at.toLocaleString(); | ||||
| </script> | ||||
| 
 | ||||
| <article class="post-reply"> | ||||
|     <div class="post-reply-avatar-container"> | ||||
|         <a href={post.user.url} target="_blank" class="post-avatar-container"> | ||||
|             <img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async"> | ||||
|         </a> | ||||
|         <div class="line"> | ||||
|         </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="post-reply-main"> | ||||
|         <div class="post-header-container"> | ||||
|             <header class="post-header"> | ||||
|                 <div class="post-user-info"> | ||||
|                     <a href={post.user.url} target="_blank" class="name">{@html post.user.rich_name}</a> | ||||
|                     <span class="username">{post.user.mention}</span> | ||||
|                 </div> | ||||
|                 <div class="post-info"> | ||||
|                     <a href={post.url} target="_blank" class="created-at"> | ||||
|                         <time title={time_string}>{short_time(post.created_at)}</time> | ||||
|                         {#if post.visibility !== "public"} | ||||
|                             <span class="post-visibility">({post.visibility})</span> | ||||
|                         {/if} | ||||
|                     </a> | ||||
|                 </div> | ||||
|             </header> | ||||
|         </div> | ||||
| 
 | ||||
|         <Body post={post} /> | ||||
| 
 | ||||
|         <footer class="post-footer"> | ||||
|             <div class="post-reactions"> | ||||
|                 {#each post.reactions as reaction} | ||||
|                     <FooterButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" /> | ||||
|                 {/each} | ||||
|             </div> | ||||
|             <div class="post-actions"> | ||||
|                 <FooterButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} /> | ||||
|                 <FooterButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} /> | ||||
|                 <FooterButton icon="⭐" type="favourite" label="Favourite" /> | ||||
|                 <FooterButton icon="😃" type="react" label="React" /> | ||||
|                 <FooterButton icon="🗣️" type="quote" label="Quote" /> | ||||
|                 <FooterButton icon="🛠️" type="more" label="More" /> | ||||
|             </div> | ||||
|         </footer> | ||||
|     </div> | ||||
| </article> | ||||
| 
 | ||||
| <style> | ||||
|     .post-reply { | ||||
|         padding-bottom: 24px; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|     } | ||||
| 
 | ||||
|     .post-avatar-container { | ||||
|         display: flex; | ||||
|     } | ||||
| 
 | ||||
|     .post-reply-avatar-container { | ||||
|         margin-right: 12px; | ||||
|         margin-bottom: -24px; | ||||
|     } | ||||
| 
 | ||||
|     .post-reply-avatar-container .line { | ||||
|         position: relative; | ||||
|         left: -1px; | ||||
|         width: 50%; | ||||
|         height: calc(100% - 48px); | ||||
|         border-right: 2px solid #8888; | ||||
|     } | ||||
| 
 | ||||
|     .post-reply-main { | ||||
|         flex-grow: 1; | ||||
|     } | ||||
| 
 | ||||
|     .post-header-container { | ||||
|         margin-bottom: -6px; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|     } | ||||
| 
 | ||||
|     .post-header-container a, | ||||
|     .post-header-container a:visited { | ||||
|         color: inherit; | ||||
|         text-decoration: none; | ||||
|     } | ||||
|     .post-header-container a:hover { | ||||
|         text-decoration: underline; | ||||
|     } | ||||
| 
 | ||||
|     .post-avatar { | ||||
|         border-radius: 8px; | ||||
|         box-shadow: 2px 2px #0004; | ||||
|     } | ||||
| 
 | ||||
|     .post-header { | ||||
|         height: 48px; | ||||
|         display: flex; | ||||
|         flex-grow: 1; | ||||
|         flex-direction: row; | ||||
|     } | ||||
| 
 | ||||
|     .post-info { | ||||
|         margin-left: auto; | ||||
|     } | ||||
| 
 | ||||
|     .post-user-info { | ||||
|         margin-top: -4px; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         justify-content: center; | ||||
|     } | ||||
| 
 | ||||
|     .post-user-info a { | ||||
|         display: block; | ||||
|     } | ||||
| 
 | ||||
|     .post-user-info .name :global(.emoji) { | ||||
|         position: relative; | ||||
|         top: 4px; | ||||
|         max-height: 1.25em; | ||||
|     } | ||||
| 
 | ||||
|     .post-user-info .username { | ||||
|         opacity: .5; | ||||
|         font-size: .9em; | ||||
|     } | ||||
| 
 | ||||
|     .post-info .created-at { | ||||
|         font-size: .8em; | ||||
|     } | ||||
| 
 | ||||
|     :global(.post-body) { | ||||
|         margin-top: 0; | ||||
|     } | ||||
| 
 | ||||
|     :global(.post-body p) { | ||||
|         margin: 0; | ||||
|     } | ||||
| </style> | ||||
|  | @ -12,6 +12,7 @@ export function play_sound(name) { | |||
|         return; | ||||
|     } | ||||
|     sound.pause(); | ||||
|     sound.volume = 0.25; | ||||
|     sound.currentTime = 0; | ||||
|     sound.play(); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										134
									
								
								src/ui/Button.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								src/ui/Button.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,134 @@ | |||
| <script> | ||||
|     import { play_sound } from '../sound.js'; | ||||
|     import { createEventDispatcher } from 'svelte'; | ||||
|     const dispatch = createEventDispatcher(); | ||||
| 
 | ||||
|     export let active = false; | ||||
|     export let filled = false; | ||||
|     export let disabled = false; | ||||
|     export let centered = false; | ||||
|     export let label = undefined; | ||||
|     export let sound = "default"; | ||||
|     export let href = false; | ||||
| 
 | ||||
|     let classes = []; | ||||
|     if (active) classes = ["active"]; | ||||
|     if (filled) classes = ["filled"]; | ||||
|     if (disabled) classes = ["disabled"]; | ||||
|     if (centered) classes.push("centered"); | ||||
| 
 | ||||
|     function click() { | ||||
|         if (href) { | ||||
|             location = href; | ||||
|             return; | ||||
|         } | ||||
|         if (disabled) return; | ||||
|         play_sound(sound); | ||||
|         dispatch('click'); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <button | ||||
|         type="button" | ||||
|         class={classes.join(' ')} | ||||
|         title={label} | ||||
|         aria-label={label} | ||||
|         on:click={() => click()}> | ||||
|     <slot/> | ||||
| </button> | ||||
| 
 | ||||
| <style> | ||||
|     button { | ||||
|         /* min-width: 64px; */ | ||||
|         width: 100%; | ||||
|         height: 54px; | ||||
|         padding: 16px; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|         justify-content: space-between; | ||||
|         align-items: center; | ||||
| 
 | ||||
|         font-family: inherit; | ||||
|         font-size: 1rem; | ||||
|         font-weight: 600; | ||||
|         text-align: left; | ||||
| 
 | ||||
|         border-radius: 8px; | ||||
|         border-width: 2px; | ||||
|         border-style: solid; | ||||
| 
 | ||||
|         background-color: var(--bg-700); | ||||
|         color: var(--text); | ||||
|         border-color: transparent; | ||||
| 
 | ||||
|         transition-property: border-color, background-color, color; | ||||
|         transition-timing-function: ease-out; | ||||
|         transition-duration: .1s; | ||||
| 
 | ||||
|         cursor: pointer; | ||||
|     } | ||||
| 
 | ||||
|     button.centered { | ||||
|         text-align: center; | ||||
|         justify-content: center; | ||||
|     } | ||||
| 
 | ||||
|     button:hover { | ||||
|         background-color: color-mix(in srgb, var(--bg-700), var(--accent) 10%); | ||||
|         border-color: color-mix(in srgb, var(--bg-700), var(--accent) 20%); | ||||
|     } | ||||
| 
 | ||||
|     button:active { | ||||
|         background-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 50%); | ||||
|         border-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 10%); | ||||
|     } | ||||
| 
 | ||||
|     button.active { | ||||
|         background-color: var(--bg-600); | ||||
|         color: var(--accent); | ||||
|         border-color: var(--accent); | ||||
|         text-shadow: 0px 2px 32px var(--accent); | ||||
|     } | ||||
| 
 | ||||
|     button.active:hover { | ||||
|         color: color-mix(in srgb, var(--accent), var(--bg-1000) 20%); | ||||
|         border-color: color-mix(in srgb, var(--accent), var(--bg-1000) 20%); | ||||
|         background-color: color-mix(in srgb, var(--bg-600), var(--accent) 10%); | ||||
|     } | ||||
| 
 | ||||
|     button.active:active { | ||||
|         color: color-mix(in srgb, var(--accent), var(--bg-800) 10%); | ||||
|         border-color: color-mix(in srgb, var(--accent), var(--bg-800) 10%); | ||||
|         background-color: color-mix(in srgb, var(--bg-600), var(--bg-800) 10%); | ||||
|     } | ||||
| 
 | ||||
|     button.filled { | ||||
|         background-color: var(--accent); | ||||
|         color: var(--bg-800); | ||||
|         border-color: transparent; | ||||
|     } | ||||
| 
 | ||||
|     button.filled:hover { | ||||
|         color: color-mix(in srgb, var(--bg-800), white 10%); | ||||
|         background-color: color-mix(in srgb, var(--accent), white 20%); | ||||
|     } | ||||
| 
 | ||||
|     button.filled:active { | ||||
|         color: color-mix(in srgb, var(--bg-800), black 10%); | ||||
|         background-color: color-mix(in srgb, var(--accent), black 20%); | ||||
|     } | ||||
| 
 | ||||
|     button.disabled { | ||||
|         background-color: var(--bg-700); | ||||
|         color: var(--text); | ||||
|         opacity: .5; | ||||
|         border-color: transparent; | ||||
|         cursor: initial; | ||||
|     } | ||||
| 
 | ||||
|     button.disabled:hover { | ||||
|     } | ||||
| 
 | ||||
|     button.disabled:active { | ||||
|     } | ||||
| </style> | ||||
							
								
								
									
										132
									
								
								src/ui/Feed.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/ui/Feed.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,132 @@ | |||
| <script> | ||||
|     import Button from './Button.svelte'; | ||||
|     import Post from './post/Post.svelte'; | ||||
|     import Error from './Error.svelte'; | ||||
|     import { Client } from '../client/client.js'; | ||||
|     import { parsePost } from '../client/api.js'; | ||||
|     import { get } from 'svelte/store'; | ||||
| 
 | ||||
|     let params = new URLSearchParams(location.search); | ||||
| 
 | ||||
|     let client = get(Client.get()); | ||||
|     let posts = []; | ||||
|     let loading = false; | ||||
|     let focus_post_id = location.pathname.startsWith("/post/") ? location.pathname.substring(6) : false; | ||||
| 
 | ||||
|     let error; | ||||
| 
 | ||||
|     async function getTimeline() { | ||||
|         if (loading) return; // no spamming!! | ||||
|         loading = true; | ||||
| 
 | ||||
|         let timeline_data; | ||||
|         if (posts.length === 0) timeline_data = await client.getTimeline() | ||||
|         else timeline_data = await client.getTimeline(posts[posts.length - 1].id); | ||||
| 
 | ||||
|         if (!timeline_data) { | ||||
|             console.error(`Failed to retrieve timeline.`); | ||||
|             loading = false; | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         for (let i in timeline_data) { | ||||
|             const post_data = timeline_data[i]; | ||||
|             const post = await parsePost(post_data, 1, false); | ||||
|             if (!post) { | ||||
|                 if (post === null || post === undefined) { | ||||
|                     if (post_data.id) { | ||||
|                         console.warn("Failed to parse post #" + post_data.id); | ||||
|                     } else { | ||||
|                         console.warn("Failed to parse post:"); | ||||
|                         console.warn(post_data); | ||||
|                     } | ||||
|                 } | ||||
|                 continue; | ||||
|             } | ||||
|             posts = [...posts, post]; | ||||
|         } | ||||
|         loading = false; | ||||
|     } | ||||
| 
 | ||||
|     async function getPost(post_id) { | ||||
|         loading = true; | ||||
| 
 | ||||
|         const post_data = await client.getPost(post_id); | ||||
|         if (!post_data) { | ||||
|             console.error(`Failed to retrieve post ${post_id}.`); | ||||
|             loading = false; | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const post = await parsePost(post_data, 10, true); | ||||
|         posts = [post]; | ||||
|         for (let i in post.replies) { | ||||
|             posts = [...posts, post.replies[i]]; | ||||
|         } | ||||
|         loading = false; | ||||
|     } | ||||
| 
 | ||||
|     if (focus_post_id) { | ||||
|         getPost(focus_post_id); | ||||
|     } else { | ||||
|         getTimeline(); | ||||
|         document.addEventListener("scroll", event => { | ||||
|             if (!loading && window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) { | ||||
|                 getTimeline(); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <header> | ||||
|     <h1>Home</h1> | ||||
|     <nav> | ||||
|         <Button centered active>Home</Button> | ||||
|         <Button centered disabled>Local</Button> | ||||
|         <Button centered disabled>Federated</Button> | ||||
|     </nav> | ||||
| </header> | ||||
| 
 | ||||
| <div id="feed"> | ||||
|     {#if posts.length <= 0} | ||||
|         <div class="throb"> | ||||
|             <span>just a moment...</span> | ||||
|         </div> | ||||
|     {/if} | ||||
|     {#each posts as post} | ||||
|         <Post post_data={post} focused={post.id === focus_post_id} /> | ||||
|     {/each} | ||||
| </div> | ||||
| 
 | ||||
| <style> | ||||
|     header { | ||||
|         margin: 16px 0 8px 0; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|     } | ||||
| 
 | ||||
|     header h1 { | ||||
|         font-size: 1.5em; | ||||
|     } | ||||
| 
 | ||||
|     nav { | ||||
|         margin-left: auto; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|         gap: 8px; | ||||
|     } | ||||
| 
 | ||||
|     #feed { | ||||
|         margin-bottom: 20vh; | ||||
|     } | ||||
| 
 | ||||
|     .throb { | ||||
|         width: 100%; | ||||
|         height: 80vh; | ||||
|         display: flex; | ||||
|         justify-content: center; | ||||
|         align-items: center; | ||||
|         font-size: 2em; | ||||
|         font-weight: bold; | ||||
|     } | ||||
| </style> | ||||
							
								
								
									
										281
									
								
								src/ui/Navigation.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										281
									
								
								src/ui/Navigation.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,281 @@ | |||
| <script> | ||||
|     import { version as APP_VERSION } from '../../package.json'; | ||||
|     import Logo from '../img/spacesocial-logo.svg'; | ||||
|     import Button from './Button.svelte'; | ||||
|     import Feed from './Feed.svelte'; | ||||
|     import { Client } from '../client/client.js'; | ||||
|     import { play_sound } from '../sound.js'; | ||||
| 
 | ||||
|     let client = false; | ||||
|     Client.get().subscribe(c => { | ||||
|         client = c; | ||||
|     }); | ||||
| 
 | ||||
|     let notification_count = 0; | ||||
|     if (notification_count > 99) notification_count = "99+"; | ||||
| 
 | ||||
|     function goTimeline() { | ||||
|         location = "/"; | ||||
|     } | ||||
| 
 | ||||
|     function log_out() { | ||||
|         if (!confirm("This will log you out. Are you sure?")) return; | ||||
|         client.logout().then(() => { | ||||
|             location = "/"; | ||||
|         }); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <div id="navigation"> | ||||
|     <header id="instance-header"> <!-- style={`background-image: url(${banner_url})`}> --> | ||||
|         <!-- <img src={icon_url} class="instance-icon" height="92px" aria-hidden="true"> --> | ||||
|         <div class="instance-icon instance-icon-mask" style={`mask-image: url(${Logo})`} height="92px" aria-hidden="true"> | ||||
|         <!-- <img src={Logo} class="instance-icon" height="92px" aria-hidden="true"> --> | ||||
|     </header> | ||||
| 
 | ||||
|     <div id="nav-items"> | ||||
|         <Button label="Timeline" on:click={() => goTimeline()} active>🖼️ Timeline</Button> | ||||
|         <Button label="Notifications" disabled> | ||||
|             🔔 Notifications | ||||
|             {#if notification_count} | ||||
|             <span class="notification-count">{notification_count}</span> | ||||
|             {/if} | ||||
|         </Button> | ||||
|         <Button label="Explore" disabled>🌍 Explore</Button> | ||||
|         <Button label="Lists" disabled>🗒️ Lists</Button> | ||||
| 
 | ||||
|         <div class="flex-row"> | ||||
|             <Button centered label="Favourites" disabled>⭐</Button> | ||||
|             <Button centered label="Bookmarks" disabled>🔖</Button> | ||||
|             <Button centered label="Hashtags" disabled>#</Button> | ||||
|         </div> | ||||
| 
 | ||||
|         <Button filled label="Post" disabled>✏️ Post</Button> | ||||
|     </div> | ||||
| 
 | ||||
|     {#if (client.user)} | ||||
|     <div id="account-items"> | ||||
|         <div class="flex-row"> | ||||
|             <Button centered label="Profile information" disabled>ℹ️</Button> | ||||
|             <Button centered label="Settings" disabled>⚙️</Button> | ||||
|             <Button centered label="Log out" on:click={() => log_out()}>🚪</Button> | ||||
|         </div> | ||||
| 
 | ||||
|         <div id="account-button"> | ||||
|             <img src={client.user.avatar_url} class="account-avatar" height="64px" aria-hidden="true" on:click={() => play_sound()}> | ||||
|             <div class="account-name" aria-hidden="true"> | ||||
|                 <span class="nickname" title={client.user.nickname}>{client.user.nickname}</span> | ||||
|                 <span class="username" title={`@${client.user.username}@${client.user.host}`}> | ||||
|                     {`@${client.user.username}@${client.user.host}`} | ||||
|                 </span> | ||||
|             </div> | ||||
|             <!-- <button class="settings" aria-label={`Account: ${client.user.username}@${client.user.host}`} on:click={() => play_sound()}>🔧</button> --> | ||||
|         </div> | ||||
|     </div> | ||||
|     {/if} | ||||
|     <span class="version"> | ||||
|         space social v{APP_VERSION} | ||||
|         <br> | ||||
|         <ul> | ||||
|             <li><a href="https://git.arimelody.me/ari/spacesocial-client">source</a></li> | ||||
|         </ul> | ||||
|     </span> | ||||
| </div> | ||||
| 
 | ||||
| <style> | ||||
|     #navigation { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         position: fixed; | ||||
|         top: 16px; | ||||
|         width: 300px; | ||||
|         height: calc(100vh - 32px); | ||||
|         border-radius: 8px; | ||||
|         background-color: var(--bg-800); | ||||
|     } | ||||
| 
 | ||||
|     #instance-header { | ||||
|         width: 100%; | ||||
|         height: 172px; | ||||
|         display: flex; | ||||
|         justify-content: center; | ||||
|         align-items: center; | ||||
|         border-radius: 8px; | ||||
|         background-position: center; | ||||
|         background-size: cover; | ||||
|         background-color: var(--bg-600); | ||||
|         background-image: linear-gradient(to top, var(--bg-800), var(--bg-600)); | ||||
|     } | ||||
| 
 | ||||
|     .instance-icon { | ||||
|         height: 92px; | ||||
|         border-radius: 8px; | ||||
|     } | ||||
| 
 | ||||
|     .instance-icon-mask { | ||||
|         width: 80%; | ||||
|         margin: auto; | ||||
|         background-color: var(--text); | ||||
|         mask-repeat: no-repeat; | ||||
|         -webkit-mask-repeat: no-repeat; | ||||
|         mask-origin: border-box; | ||||
|         -webkit-mask-origin: border-box; | ||||
|     } | ||||
| 
 | ||||
|     #nav-items { | ||||
|         margin-bottom: auto; | ||||
|         padding: 16px; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 8px; | ||||
|     } | ||||
| 
 | ||||
|     .notification-count { | ||||
|         position: relative; | ||||
|         transform: translate(22px, -16px); | ||||
|         min-width: 12px; | ||||
|         height: 28px; | ||||
|         padding: 0 8px; | ||||
|         display: flex; | ||||
|         justify-content: center; | ||||
|         align-items: center; | ||||
|         text-align: right; | ||||
|         border-radius: 8px; | ||||
|         font-weight: 700; | ||||
|         color: var(--bg-1000); | ||||
|         background-color: var(--accent); | ||||
|         box-shadow: 0 0 32px color-mix(in srgb, transparent, var(--accent) 100%); | ||||
|     } | ||||
| 
 | ||||
|     #account-items { | ||||
|         padding: 16px; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 8px; | ||||
|     } | ||||
| 
 | ||||
|     .version { | ||||
|         margin-bottom: 16px; | ||||
|         font-style: italic; | ||||
|         font-size: .9em; | ||||
|         opacity: .6; | ||||
|         text-align: center; | ||||
|     } | ||||
| 
 | ||||
|     .version ul { | ||||
|         margin: 0; | ||||
|         padding: 0; | ||||
|         display: flex; | ||||
|         gap: 8px; | ||||
|         justify-content: center; | ||||
|         list-style: none; | ||||
|     } | ||||
| 
 | ||||
|     .version ul li { | ||||
|         margin: 0; | ||||
|     } | ||||
| 
 | ||||
|     .version ul li:not(:first-child):before { | ||||
|         content: '•'; | ||||
|         margin-right: 8px; | ||||
|         color: inherit; | ||||
|         opacity: .7; | ||||
|     } | ||||
| 
 | ||||
|     .version a { | ||||
|         color: inherit; | ||||
|         text-decoration: none; | ||||
|         opacity: .7; | ||||
|     } | ||||
| 
 | ||||
|     .version a:hover { | ||||
|         text-decoration: underline; | ||||
|     } | ||||
| 
 | ||||
|     #account-button { | ||||
|         width: calc(100% - 16px); | ||||
|         height: 48px; | ||||
|         padding: 8px; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|         align-items: center; | ||||
|         gap: 8px; | ||||
| 
 | ||||
|         font-family: inherit; | ||||
|         font-size: 1rem; | ||||
|         font-weight: 600; | ||||
| 
 | ||||
|         border-radius: 8px; | ||||
|         background-color: var(--bg-700); | ||||
|         color: var(--text); | ||||
|         border-color: transparent; | ||||
| 
 | ||||
|         transition-property: border-color, background-color, color; | ||||
|         transition-timing-function: ease-out; | ||||
|         transition-duration: .1s; | ||||
| 
 | ||||
|         cursor: pointer; | ||||
|     } | ||||
| 
 | ||||
|     .account-avatar { | ||||
|         width: 48px; | ||||
|         height: 48px; | ||||
|         border-radius: 8px; | ||||
|         transition: transform .1s ease-out, box-shadow .2s; | ||||
|     } | ||||
| 
 | ||||
|     .account-avatar:hover { | ||||
|         transform: scale(1.05); | ||||
|         box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 25%); | ||||
|     } | ||||
| 
 | ||||
|     .account-avatar:active { | ||||
|         transform: scale(.95); | ||||
|         box-shadow: 0 0 16px var(--bg-1000); | ||||
|     } | ||||
| 
 | ||||
|     .account-name { | ||||
|         /* width: 152px; */ | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 2px; | ||||
|     } | ||||
| 
 | ||||
|     .username, .nickname { | ||||
|         text-overflow: ellipsis; | ||||
|         overflow: hidden; | ||||
|         white-space: nowrap; | ||||
|         font-size: .8em; | ||||
|     } | ||||
| 
 | ||||
|     .username { | ||||
|         opacity: .8; | ||||
|         font-size: .65em; | ||||
|     } | ||||
| 
 | ||||
|     .settings { | ||||
|         width: 32px; | ||||
|         height: 32px; | ||||
|         padding: none; | ||||
|         border: none; | ||||
|         font-size: inherit; | ||||
|         font-family: inherit; | ||||
|         background: none; | ||||
|         border-radius: 8px; | ||||
|         transition: background-color .1s; | ||||
|     } | ||||
| 
 | ||||
|     .settings:hover { | ||||
|         background-color: color-mix(in srgb, var(--bg-700), var(--text) 15%); | ||||
|     } | ||||
| 
 | ||||
|     .settings:active { | ||||
|         background-color: color-mix(in srgb, var(--bg-700), var(--bg-1000) 30%); | ||||
|     } | ||||
| 
 | ||||
|     .flex-row { | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|         gap: 8px; | ||||
|     } | ||||
| </style> | ||||
							
								
								
									
										39
									
								
								src/ui/Widgets.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/ui/Widgets.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,39 @@ | |||
| <div id="widgets"> | ||||
|     <input type="text" id="search" placeholder="🔍 Search"> | ||||
| </div> | ||||
| 
 | ||||
| <style> | ||||
|     #widgets { | ||||
|         position: fixed; | ||||
|         top: 16px; | ||||
|         width: 300px; | ||||
|         height: calc(100vh - 32px); | ||||
| 
 | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 8px; | ||||
|     } | ||||
| 
 | ||||
|     #search { | ||||
|         padding: 16px; | ||||
|         border-radius: 8px; | ||||
|         border: 1px solid color-mix(in srgb, transparent, var(--accent) 25%); | ||||
|         background-color: var(--bg-800); | ||||
| 
 | ||||
|         font-family: inherit; | ||||
|         font-weight: 600; | ||||
|         font-size: inherit; | ||||
|         color: var(--text); | ||||
| 
 | ||||
|         transition: box-shadow .2s; | ||||
|     } | ||||
| 
 | ||||
|     #search::placeholder { | ||||
|         opacity: .8; | ||||
|     } | ||||
| 
 | ||||
|     #search:focus { | ||||
|         outline: none; | ||||
|         box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 25%); | ||||
|     } | ||||
| </style> | ||||
|  | @ -1,5 +1,5 @@ | |||
| <script> | ||||
|     import { play_sound } from '../sound.js'; | ||||
|     import { play_sound } from '../../sound.js'; | ||||
| 
 | ||||
|     export let icon = "🔧"; | ||||
|     export let type = "action"; | ||||
|  | @ -14,7 +14,7 @@ | |||
|         class="{type}" | ||||
|         aria-label="{label}" | ||||
|         title="{title}" | ||||
|         on:click={() => (play_sound(sound))}> | ||||
|         on:click|stopPropagation={() => (play_sound(sound))}> | ||||
|         <span class="icon">{@html icon}</span> | ||||
|         {#if count} | ||||
|             <span class="count">{count}</span> | ||||
|  | @ -8,7 +8,7 @@ | |||
| 
 | ||||
| <div class="post-body"> | ||||
|     {#if post.warning} | ||||
|         <button class="post-warning" on:click={() => { open_warned = !open_warned }}> | ||||
|         <button class="post-warning" on:click|stopPropagation={() => { open_warned = !open_warned }}> | ||||
|         <strong> | ||||
|             {post.warning} | ||||
|             <span class="warning-instructions"> | ||||
|  | @ -27,14 +27,14 @@ | |||
|         {/if} | ||||
|         <div class="post-media-container" data-count={post.files.length}> | ||||
|             {#each post.files as file} | ||||
|                 <div class="post-media {file.type}"> | ||||
|                 <div class="post-media {file.type}" on:click|stopPropagation={null}> | ||||
|                     {#if file.type === "image"} | ||||
|                         <a href={file.url} target="_blank"> | ||||
|                             <img src={file.url} alt={file.description} height="200" loading="lazy" decoding="async"> | ||||
|                             <img src={file.url} alt={file.description} title={file.description} height="200" loading="lazy" decoding="async"> | ||||
|                         </a> | ||||
|                     {:else if file.type === "video"} | ||||
|                         <video controls height="200"> | ||||
|                             <source src={file.url} type={file.url.endsWith('.mp4') ? 'video/mp4' : 'video/webm'}> | ||||
|                             <source src={file.url} alt={file.description} title={file.description} type={file.url.endsWith('.mp4') ? 'video/mp4' : 'video/webm'}> | ||||
|                             <p>{file.description}   <a href={file.url}>[link]</a></p> | ||||
|                             <!-- <media src={file.url} alt={file.description} loading="lazy" decoding="async"> --> | ||||
|                         </video> | ||||
|  | @ -58,8 +58,9 @@ | |||
|         width: 100%; | ||||
|         margin-bottom: 10px; | ||||
|         padding: 4px 8px; | ||||
|         --warn-bg: rgba(255,220,30,.1); | ||||
|         --warn-bg: color-mix(in srgb, var(--bg-700), var(--accent) 1%); | ||||
|         background: repeating-linear-gradient(-45deg, transparent, transparent 10px, var(--warn-bg) 10px, var(--warn-bg) 20px); | ||||
|         font-family: inherit; | ||||
|         font-size: inherit; | ||||
|         color: inherit; | ||||
|         text-align: left; | ||||
|  | @ -125,9 +126,10 @@ | |||
|     } | ||||
| 
 | ||||
|     .post-text :global(a.mention) { | ||||
|         color: var(--accent); | ||||
|         color: inherit; | ||||
|         font-weight: 600; | ||||
|         padding: 3px 6px; | ||||
|         background: var(--accent-bg); | ||||
|         background: var(--bg-700); | ||||
|         border-radius: 6px; | ||||
|         text-decoration: none; | ||||
|     } | ||||
|  | @ -1,6 +1,6 @@ | |||
| <script> | ||||
|     import { parseText as parseEmojis } from '../emoji.js'; | ||||
|     import { shorthand as short_time } from '../time.js'; | ||||
|     import { parseText as parseEmojis } from '../../emoji.js'; | ||||
|     import { shorthand as short_time } from '../../time.js'; | ||||
| 
 | ||||
|     export let post; | ||||
| 
 | ||||
|  | @ -27,7 +27,8 @@ | |||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|         align-items: center; | ||||
|         color: var(--accent); | ||||
|         font-weight: 600; | ||||
|         color: var(--text); | ||||
|         opacity: .8; | ||||
|         transition: opacity .1s; | ||||
|     } | ||||
							
								
								
									
										113
									
								
								src/ui/post/Post.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/ui/post/Post.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,113 @@ | |||
| <script> | ||||
|     import BoostContext from './BoostContext.svelte'; | ||||
|     import ReplyContext from './ReplyContext.svelte'; | ||||
|     import PostHeader from './PostHeader.svelte'; | ||||
|     import Body from './Body.svelte'; | ||||
|     import ReactionButton from './ReactionButton.svelte'; | ||||
|     import ActionButton from './ActionButton.svelte'; | ||||
|     import { parseOne as parseEmoji } from '../../emoji.js'; | ||||
|     import { play_sound } from '../../sound.js'; | ||||
|     import { onMount } from 'svelte'; | ||||
| 
 | ||||
|     export let post_data; | ||||
|     export let focused = false; | ||||
| 
 | ||||
|     let post_context = undefined; | ||||
|     let post = post_data; | ||||
|     let is_boost = false; | ||||
|     if (post_data.boost) { | ||||
|         is_boost = true; | ||||
|         post_context = post_data; | ||||
|         post = post_data.boost; | ||||
|     } | ||||
| 
 | ||||
|     function gotoPost() { | ||||
|         location = `/post/${post.id}`; | ||||
|     } | ||||
| 
 | ||||
|     let el; | ||||
|     onMount(() => { | ||||
|         if (focused) { | ||||
|             window.scrollTo(0, el.scrollHeight - 700); | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at; | ||||
| </script> | ||||
| 
 | ||||
| <div class="post-container" aria-label={aria_label} bind:this={el}> | ||||
|     {#if post.reply} | ||||
|         <ReplyContext post={post.reply} /> | ||||
|     {/if} | ||||
|     {#if is_boost && !post_context.text} | ||||
|         <BoostContext post={post_context} /> | ||||
|     {/if} | ||||
|     <article class={"post" + (focused ? " focused" : "")} on:click={!focused ? gotoPost() : null}> | ||||
|         <PostHeader post={post} /> | ||||
|         <Body post={post} /> | ||||
|         <footer class="post-footer"> | ||||
|             <div class="post-reactions"> | ||||
|                 {#each post.reactions as reaction} | ||||
|                     <ReactionButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" /> | ||||
|                 {/each} | ||||
|             </div> | ||||
|             <div class="post-actions"> | ||||
|                 <ActionButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} sound="post" /> | ||||
|                 <ActionButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} sound="boost" /> | ||||
|                 <ActionButton icon="⭐" type="favourite" label="Favourite" /> | ||||
|                 <ActionButton icon="😃" type="react" label="React" /> | ||||
|                 <ActionButton icon="🗣️" type="quote" label="Quote" /> | ||||
|                 <ActionButton icon="🛠️" type="more" label="More" /> | ||||
|             </div> | ||||
|         </footer> | ||||
|     </article> | ||||
| </div> | ||||
| 
 | ||||
| <style> | ||||
|     .post-container { | ||||
|         width: 700px; | ||||
|         max-width: 700px; | ||||
|         margin-bottom: 8px; | ||||
|         padding: 16px; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         border-radius: 8px; | ||||
|         background-color: var(--bg-800); | ||||
|         transition: background-color .1s; | ||||
|     } | ||||
| 
 | ||||
|     .post-container:hover { | ||||
|         background-color: color-mix(in srgb, var(--bg-800), black 5%); | ||||
|     } | ||||
| 
 | ||||
|     .post-container:hover :global(.post-context) { | ||||
|         opacity: 1; | ||||
|     } | ||||
|      | ||||
|     .post:not(.focused) { | ||||
|         cursor: pointer; | ||||
|     } | ||||
| 
 | ||||
|     .post.focused { | ||||
|         padding: 16px; | ||||
|         margin: -16px; | ||||
|         border-radius: 8px; | ||||
|         border: 1px solid color-mix(in srgb, transparent, var(--accent) 20%); | ||||
|         box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 20%); | ||||
|     } | ||||
| 
 | ||||
|     :global(.post-reactions) { | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|     } | ||||
| 
 | ||||
|     :global(.post-actions) { | ||||
|         margin-top: 8px; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|     } | ||||
| 
 | ||||
|     .post-container :global(.emoji) { | ||||
|         height: 20px; | ||||
|     } | ||||
| </style> | ||||
|  | @ -1,13 +1,14 @@ | |||
| <script> | ||||
|     import { parseText as parseEmojis } from '../emoji.js'; | ||||
|     import { shorthand as short_time } from '../time.js'; | ||||
|     import { parseText as parseEmojis } from '../../emoji.js'; | ||||
|     import { shorthand as short_time } from '../../time.js'; | ||||
| 
 | ||||
|     export let post; | ||||
|     export let reply = undefined; | ||||
| 
 | ||||
|     let time_string = post.created_at.toLocaleString(); | ||||
| </script> | ||||
| 
 | ||||
| <div class="post-header-container"> | ||||
| <div class={"post-header-container" + (reply ? " reply" : "")}> | ||||
|     <a href={post.user.url} target="_blank" class="post-avatar-container"> | ||||
|         <img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async"> | ||||
|     </a> | ||||
|  | @ -20,7 +21,8 @@ | |||
|             <a href={post.url} target="_blank" class="created-at"> | ||||
|                 <time title={time_string}>{short_time(post.created_at)}</time> | ||||
|                 {#if post.visibility !== "public"} | ||||
|                     <span class="post-visibility">({post.visibility})</span> | ||||
|                     <br> | ||||
|                     <span class="post-visibility">{post.visibility}</span> | ||||
|                 {/if} | ||||
|             </a> | ||||
|         </div> | ||||
|  | @ -29,10 +31,16 @@ | |||
| 
 | ||||
| <style> | ||||
|     .post-header-container { | ||||
|         width: 100%; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|     } | ||||
| 
 | ||||
|     .post-header-container.reply { | ||||
|         width: calc(100% + 60px); | ||||
|         margin-left: -60px; | ||||
|     } | ||||
| 
 | ||||
|     .post-header-container a, | ||||
|     .post-header-container a:visited { | ||||
|         color: inherit; | ||||
|  | @ -49,7 +57,6 @@ | |||
| 
 | ||||
|     .post-avatar { | ||||
|         border-radius: 8px; | ||||
|         box-shadow: 2px 2px #0004; | ||||
|     } | ||||
| 
 | ||||
|     .post-header { | ||||
|  | @ -80,11 +87,21 @@ | |||
|     } | ||||
| 
 | ||||
|     .post-user-info .username { | ||||
|         opacity: .5; | ||||
|         opacity: .8; | ||||
|         font-size: .9em; | ||||
|     } | ||||
| 
 | ||||
|     .post-info .created-at { | ||||
|         height: 100%; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         align-items: end; | ||||
|         justify-content: center; | ||||
|         font-size: .8em; | ||||
|     } | ||||
| 
 | ||||
|     .post-visibility { | ||||
|         font-size: .9em; | ||||
|         opacity: .8; | ||||
|     } | ||||
| </style> | ||||
							
								
								
									
										66
									
								
								src/ui/post/ReactionButton.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/ui/post/ReactionButton.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | |||
| <script> | ||||
|     import { play_sound } from '../../sound.js'; | ||||
| 
 | ||||
|     export let icon = "🔧"; | ||||
|     export let type = "action"; | ||||
|     export let label = "Action"; | ||||
|     export let title = label; | ||||
|     export let count = 0; | ||||
|     export let sound = "default"; | ||||
| </script> | ||||
| 
 | ||||
| <button | ||||
|         type="button" | ||||
|         class="{type}" | ||||
|         aria-label="{label}" | ||||
|         title="{title}" | ||||
|         on:click|stopPropagation={() => (play_sound(sound))}> | ||||
|         <span class="icon">{@html icon}</span> | ||||
|         {#if count} | ||||
|             <span class="count">{count}</span> | ||||
|         {/if} | ||||
| </button> | ||||
| 
 | ||||
| <style> | ||||
|     button { | ||||
|         height: 32px; | ||||
|         padding: 6px 8px; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|         gap: 4px; | ||||
|         font-size: 1em; | ||||
|         background: none; | ||||
|         color: inherit; | ||||
|         border: none; | ||||
|         border-radius: 8px; | ||||
|     } | ||||
| 
 | ||||
|     button.active { | ||||
|         background: var(--accent); | ||||
|         color: var(--bg0); | ||||
|     } | ||||
| 
 | ||||
|     button:hover { | ||||
|         background: #8881; | ||||
|     } | ||||
| 
 | ||||
|     button:active { | ||||
|         background: #0001; | ||||
|     } | ||||
| 
 | ||||
|     .icon { | ||||
|         width: 20px; | ||||
|         height: 20px; | ||||
|         display: flex; | ||||
|         justify-content: center; | ||||
|         align-items: center; | ||||
|     } | ||||
| 
 | ||||
|     .count { | ||||
|         opacity: .5; | ||||
|     } | ||||
| 
 | ||||
|     button:hover .count { | ||||
|         opacity: 1; | ||||
|     } | ||||
| </style> | ||||
							
								
								
									
										81
									
								
								src/ui/post/ReplyContext.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/ui/post/ReplyContext.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,81 @@ | |||
| <script> | ||||
|     import PostHeader from './PostHeader.svelte'; | ||||
|     import Body from './Body.svelte'; | ||||
|     import ReactionButton from './ReactionButton.svelte'; | ||||
|     import ActionButton from './ActionButton.svelte'; | ||||
|     import Post from './Post.svelte'; | ||||
|     import { parseText as parseEmojis, parseOne as parseEmoji } from '../../emoji.js'; | ||||
|     import { shorthand as short_time } from '../../time.js'; | ||||
| 
 | ||||
|     export let post; | ||||
|     let time_string = post.created_at.toLocaleString(); | ||||
| 
 | ||||
|     function gotoPost() { | ||||
|         location = `/post/${post.id}`; | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| {#if post.reply} | ||||
|     <svelte:self post={post.reply} /> | ||||
| {/if} | ||||
| 
 | ||||
| <article class="post-reply" on:click={() => gotoPost()}> | ||||
|     <div class="line"></div> | ||||
|          | ||||
|     <div class="post-reply-main"> | ||||
|         <PostHeader post={post} reply /> | ||||
| 
 | ||||
|         <Body post={post} /> | ||||
| 
 | ||||
|         <footer class="post-footer"> | ||||
|             <div class="post-reactions"> | ||||
|                 {#each post.reactions as reaction} | ||||
|                     <ReactionButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" /> | ||||
|                 {/each} | ||||
|             </div> | ||||
|             <div class="post-actions"> | ||||
|                 <ActionButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} sound="post" /> | ||||
|                 <ActionButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} sound="boost" /> | ||||
|                 <ActionButton icon="⭐" type="favourite" label="Favourite" /> | ||||
|                 <ActionButton icon="😃" type="react" label="React" /> | ||||
|                 <ActionButton icon="🗣️" type="quote" label="Quote" /> | ||||
|                 <ActionButton icon="🛠️" type="more" label="More" /> | ||||
|             </div> | ||||
|         </footer> | ||||
|     </div> | ||||
| </article> | ||||
| 
 | ||||
| <style> | ||||
|     .post-reply { | ||||
|         padding-bottom: 24px; | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|         color: var(--text); | ||||
|         align-items: stretch; | ||||
|     } | ||||
| 
 | ||||
|     .post-avatar-container { | ||||
|         display: flex; | ||||
|     } | ||||
| 
 | ||||
|     .line { | ||||
|         position: relative; | ||||
|         top: 24px; | ||||
|         left: 25px; | ||||
|         border-right: 2px solid var(--bg-700); | ||||
|     } | ||||
| 
 | ||||
|     .post-reply-main { | ||||
|         width: 100%; | ||||
|         padding-left: 60px; | ||||
|         z-index: 1; | ||||
|     } | ||||
| 
 | ||||
|     :global(.post-body) { | ||||
|         margin-top: 0; | ||||
|     } | ||||
| 
 | ||||
|     :global(.post-body p) { | ||||
|         margin: 0; | ||||
|     } | ||||
| </style> | ||||
|  | @ -1,5 +1,6 @@ | |||
| import { Client } from '../client/client.js'; | ||||
| import { parseText as parseEmojis } from '../emoji.js'; | ||||
| import { get } from 'svelte/store'; | ||||
| 
 | ||||
| export default class User { | ||||
|     id; | ||||
|  | @ -16,7 +17,7 @@ export default class User { | |||
| 
 | ||||
|     get mention() { | ||||
|         let res = "@" + this.username; | ||||
|         if (this.host != Client.get().instance.host) | ||||
|         if (this.host != get(Client.get()).instance.host) | ||||
|             res += "@" + this.host; | ||||
|         return res; | ||||
|     } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue