JavaScript-optional public web frontend for Bluesky anartia.kelinci.net
sveltekit atcute bluesky typescript svelte
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: content blurring

Mary b62adc4d 1ae89790

+344 -62
+91
src/lib/components/content-hider.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + 4 + import type { LabelDefinition } from '$lib/moderation'; 5 + 6 + interface Props { 7 + blur: LabelDefinition | undefined; 8 + children: Snippet<[]>; 9 + } 10 + 11 + const { blur, children }: Props = $props(); 12 + </script> 13 + 14 + {#if !blur} 15 + {@render children()} 16 + {:else} 17 + <details class="content-hider"> 18 + <summary class="gate"> 19 + <svg class="icon" fill="none" viewBox="0 0 24 24"> 20 + <path 21 + stroke="currentColor" 22 + stroke-linecap="square" 23 + stroke-width="2" 24 + d="M11 11h1v5m9-4a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 25 + /> 26 + <path 27 + fill="currentColor" 28 + stroke="currentColor" 29 + stroke-width=".5" 30 + d="M11.5 7.25h-.25v1.5h1.5v-1.5H11.5Z" 31 + /> 32 + </svg> 33 + 34 + <span class="label">{blur.name}</span> 35 + 36 + <span class="action"></span> 37 + </summary> 38 + 39 + {@render children()} 40 + </details> 41 + {/if} 42 + 43 + <style> 44 + .gate { 45 + display: flex; 46 + align-items: center; 47 + gap: 12px; 48 + cursor: pointer; 49 + border: 1px solid var(--divider-md); 50 + border-radius: 6px; 51 + padding: 0 12px; 52 + height: 44px; 53 + 54 + .content-hider[open] & { 55 + margin-bottom: 12px; 56 + } 57 + 58 + @media (hover: hover) { 59 + &:hover { 60 + background: var(--tap-sm); 61 + } 62 + } 63 + } 64 + 65 + .icon { 66 + width: 18px; 67 + height: 18px; 68 + color: var(--text-blurb); 69 + } 70 + .label { 71 + flex-grow: 1; 72 + overflow: hidden; 73 + font-weight: 500; 74 + user-select: none; 75 + text-overflow: ellipsis; 76 + } 77 + 78 + .action { 79 + color: var(--text-link); 80 + font-weight: 500; 81 + font-size: 0.8125rem; 82 + line-height: 1.25rem; 83 + 84 + &::before { 85 + content: 'Show'; 86 + } 87 + .content-hider[open] &::before { 88 + content: 'Hide'; 89 + } 90 + } 91 + </style>
+18 -10
src/lib/components/embeds/embeds.svelte
··· 27 27 Brand, 28 28 } from '@atcute/client/lexicons'; 29 29 30 + import { findLabel, FlagsBlurMedia } from '$lib/moderation'; 30 31 import { parseAtUri } from '$lib/types/at-uri'; 32 + 33 + import ContentHider from '../content-hider.svelte'; 31 34 32 35 import ExternalEmbed from './external-embed.svelte'; 33 36 import FeedEmbed from './feed-embed.svelte'; ··· 44 47 interface Props { 45 48 embed: Embed; 46 49 large?: boolean; 50 + post?: AppBskyFeedDefs.PostView; 47 51 } 48 52 49 - const { embed, large = false }: Props = $props(); 53 + const { embed, large = false, post }: Props = $props(); 50 54 </script> 51 55 52 56 <div class="embeds"> ··· 61 65 </div> 62 66 63 67 {#snippet Media(embed: MediaEmbed)} 64 - {#if embed.$type === 'app.bsky.embed.external#view'} 65 - <ExternalEmbed {embed} /> 66 - {:else if embed.$type === 'app.bsky.embed.images#view'} 67 - <ImageEmbed {embed} standalone /> 68 - {:else if embed.$type === 'app.bsky.embed.video#view'} 69 - <VideoStandaloneEmbed {embed} /> 70 - {:else} 71 - {@render Message(`Unsupported media embed`)} 72 - {/if} 68 + {@const blur = post && findLabel(post.labels, post.author.did, FlagsBlurMedia)} 69 + 70 + <ContentHider {blur}> 71 + {#if embed.$type === 'app.bsky.embed.external#view'} 72 + <ExternalEmbed {embed} /> 73 + {:else if embed.$type === 'app.bsky.embed.images#view'} 74 + <ImageEmbed {embed} standalone /> 75 + {:else if embed.$type === 'app.bsky.embed.video#view'} 76 + <VideoStandaloneEmbed {embed} /> 77 + {:else} 78 + {@render Message(`Unsupported media embed`)} 79 + {/if} 80 + </ContentHider> 73 81 {/snippet} 74 82 75 83 {#snippet Record(embed: RecordEmbed)}
+4 -1
src/lib/components/embeds/feed-embed.svelte
··· 3 3 4 4 import { base } from '$app/paths'; 5 5 6 + import { findLabel, FlagsBlurMedia } from '$lib/moderation'; 6 7 import { parseAtUri } from '$lib/types/at-uri'; 7 8 8 9 import Avatar from '$lib/components/avatar.svelte'; ··· 14 15 const { embed: feed }: Props = $props(); 15 16 16 17 const creator = $derived(feed.creator); 18 + 19 + const blurAvi = $derived(!!findLabel(feed.labels, creator.did, FlagsBlurMedia)); 17 20 </script> 18 21 19 22 <a href="{base}/{creator.did}/feeds/{parseAtUri(feed.uri).rkey}" class="feed-embed"> 20 23 <div class="main"> 21 - <Avatar type="generator" src={feed.avatar} /> 24 + <Avatar type="generator" src={feed.avatar} blur={blurAvi} /> 22 25 23 26 <div class="info"> 24 27 <p class="name">{feed.displayName}</p>
+4 -1
src/lib/components/embeds/list-embed.svelte
··· 16 16 17 17 import { base } from '$app/paths'; 18 18 19 + import { findLabel, FlagsBlurMedia } from '$lib/moderation'; 19 20 import { parseAtUri } from '$lib/types/at-uri'; 20 21 21 22 import Avatar from '$lib/components/avatar.svelte'; ··· 27 28 const { embed: list }: Props = $props(); 28 29 29 30 const creator = $derived(list.creator); 31 + 32 + const blurAvi = $derived(!!findLabel(list.labels, creator.did, FlagsBlurMedia)); 30 33 </script> 31 34 32 35 <a href="{base}/{creator.did}/lists/{parseAtUri(list.uri).rkey}" class="list-embed"> 33 36 <div class="main"> 34 - <Avatar type="list" src={list.avatar} /> 37 + <Avatar type="list" src={list.avatar} blur={blurAvi} /> 35 38 36 39 <div class="info"> 37 40 <p class="name">{list.name}</p>
+49 -40
src/lib/components/embeds/quote-embed.svelte
··· 35 35 36 36 import { base } from '$app/paths'; 37 37 38 + import { findLabel, FlagsBlurContent, FlagsBlurMedia } from '$lib/moderation'; 38 39 import { parseAtUri } from '$lib/types/at-uri'; 39 40 40 41 import Avatar from '$lib/components/avatar.svelte'; 41 42 import RelativeTime from '$lib/components/islands/relative-time.svelte'; 43 + 44 + import ContentHider from '../content-hider.svelte'; 42 45 43 46 import ImageEmbed from './image-embed.svelte'; 44 47 import VideoThumbnailEmbed from './video-thumbnail-embed.svelte'; ··· 59 62 const embed = $derived(quote.embeds?.[0]); 60 63 const image = $derived(getPostImage(embed)); 61 64 const video = $derived(getPostVideo(embed)); 65 + 66 + const blurAvi = $derived(!!findLabel(author.labels, author.did, FlagsBlurMedia)); 67 + const blurContent = $derived(findLabel(quote.labels, author.did, FlagsBlurContent)); 68 + const blurMedia = $derived(!!findLabel(quote.labels, author.did, FlagsBlurMedia)); 62 69 </script> 63 70 64 - <a href="{base}/{author.did}/{parseAtUri(quote.uri).rkey}#main" class="quote-embed"> 65 - <div class="meta"> 66 - <Avatar profile={author} src={author.avatar} size="xs" /> 71 + <ContentHider blur={blurContent}> 72 + <a href="{base}/{author.did}/{parseAtUri(quote.uri).rkey}#main" class="quote-embed"> 73 + <div class="meta"> 74 + <Avatar profile={author} src={author.avatar} size="xs" blur={blurAvi} /> 67 75 68 - <span class="name-wrapper"> 69 - {#if authorName} 70 - <bdi class="display-name-wrapper"> 71 - <span class="display-name">{authorName}</span> 72 - </bdi> 73 - {/if} 76 + <span class="name-wrapper"> 77 + {#if authorName} 78 + <bdi class="display-name-wrapper"> 79 + <span class="display-name">{authorName}</span> 80 + </bdi> 81 + {/if} 74 82 75 - <span class="handle">@{author.handle}</span> 76 - </span> 83 + <span class="handle">@{author.handle}</span> 84 + </span> 77 85 78 - <span aria-hidden="true" class="dot">·</span> 86 + <span aria-hidden="true" class="dot">·</span> 79 87 80 - <span class="date"> 81 - <RelativeTime date={record.createdAt} /> 82 - </span> 83 - </div> 88 + <span class="date"> 89 + <RelativeTime date={record.createdAt} /> 90 + </span> 91 + </div> 84 92 85 - {#if text} 86 - <div class="body"> 87 - {#if !large} 88 - {#if image} 89 - <div class="aside"> 90 - <ImageEmbed embed={image} blur={false} /> 91 - </div> 92 - {:else if video} 93 - <div class="aside"> 94 - <VideoThumbnailEmbed embed={video} blur={false} /> 95 - </div> 93 + {#if text} 94 + <div class="body"> 95 + {#if !large} 96 + {#if image} 97 + <div class="aside"> 98 + <ImageEmbed embed={image} blur={blurMedia} /> 99 + </div> 100 + {:else if video} 101 + <div class="aside"> 102 + <VideoThumbnailEmbed embed={video} blur={blurMedia} /> 103 + </div> 104 + {/if} 96 105 {/if} 97 - {/if} 98 106 99 - <p class="text">{text}</p> 100 - </div> 101 - {:else} 102 - <div class="divide"></div> 103 - {/if} 107 + <p class="text">{text}</p> 108 + </div> 109 + {:else} 110 + <div class="divide"></div> 111 + {/if} 104 112 105 - {#if large || !text} 106 - {#if image} 107 - <ImageEmbed embed={image} borderless blur={false} /> 108 - {:else if video} 109 - <VideoThumbnailEmbed embed={video} borderless blur={false} /> 113 + {#if large || !text} 114 + {#if image} 115 + <ImageEmbed embed={image} borderless blur={blurMedia} /> 116 + {:else if video} 117 + <VideoThumbnailEmbed embed={video} borderless blur={blurMedia} /> 118 + {/if} 110 119 {/if} 111 - {/if} 112 - </a> 120 + </a> 121 + </ContentHider> 113 122 114 123 <style> 115 124 .quote-embed {
+6 -2
src/lib/components/embeds/video-thumbnail-embed.svelte
··· 11 11 blur?: boolean; 12 12 } 13 13 14 - const { embed: video, borderless }: Props = $props(); 14 + const { embed: video, borderless, blur }: Props = $props(); 15 15 </script> 16 16 17 17 <div class={['video-thumbnail-embed', !borderless && 'is-bordered']}> 18 18 <div class="constrainer"> 19 - <img loading="lazy" src={video.thumbnail} alt={video.alt} class="thumbnail" /> 19 + <img loading="lazy" src={video.thumbnail} alt={video.alt} class={['thumbnail', blur && 'is-blurred']} /> 20 20 21 21 <div class="play"> 22 22 <PlaySolid /> ··· 47 47 height: 100%; 48 48 object-fit: cover; 49 49 font-size: 0; 50 + } 51 + .is-blurred { 52 + scale: 125%; 53 + filter: blur(24px); 50 54 } 51 55 52 56 .play {
+1 -1
src/lib/components/timeline/post-feed-item.svelte
··· 100 100 <RichtextRenderer text={record.text} facets={record.facets} /> 101 101 102 102 {#if post.embed} 103 - <Embeds embed={post.embed} /> 103 + <Embeds {post} embed={post.embed} /> 104 104 {/if} 105 105 106 106 <PostMetrics {post} />
+149
src/lib/moderation.ts
··· 1 + import type { At, ComAtprotoLabelDefs } from '@atcute/client/lexicons'; 2 + 3 + export const FlagsNone = 0; 4 + 5 + /** Label blurs media (images and videos in posts) */ 6 + export const FlagsBlurMedia = 1 << 0; 7 + /** Label blurs content (text in posts), assumes FlagsBlurMedia */ 8 + export const FlagsBlurContent = 1 << 1; 9 + /** Label can't be self-applied */ 10 + export const FlagsNoSelf = 1 << 2; 11 + 12 + type Label = ComAtprotoLabelDefs.Label; 13 + 14 + export interface LabelDefinition { 15 + name: string; 16 + flags: number; 17 + } 18 + 19 + export const LABEL_MAPPING: Record<string, LabelDefinition> = { 20 + // Global "system" labels 21 + '!hide': { 22 + name: `Hidden by moderators`, 23 + flags: FlagsBlurContent | FlagsNoSelf, 24 + }, 25 + '!warn': { 26 + name: `Content warning`, 27 + flags: FlagsBlurContent | FlagsNoSelf, 28 + }, 29 + 30 + // Global user-applicable labels 31 + porn: { 32 + name: `Adult content`, 33 + flags: FlagsBlurMedia, 34 + }, 35 + sexual: { 36 + name: `Sexually suggestive`, 37 + flags: FlagsBlurMedia, 38 + }, 39 + 'graphic-media': { 40 + name: `Graphic media`, 41 + flags: FlagsBlurMedia, 42 + }, 43 + nudity: { 44 + name: `Nudity`, 45 + flags: FlagsBlurMedia, 46 + }, 47 + 48 + // @moderation.bsky.app's labels 49 + 'sexual-figurative': { 50 + name: `Sexually suggestive (cartoon)`, 51 + flags: FlagsBlurMedia | FlagsNoSelf, 52 + }, 53 + 54 + 'self-harm': { 55 + name: `Self-harm`, 56 + flags: FlagsBlurContent | FlagsNoSelf, 57 + }, 58 + sensitive: { 59 + name: `Sensitive content`, 60 + flags: FlagsBlurContent | FlagsNoSelf, 61 + }, 62 + extremist: { 63 + name: `Extremism`, 64 + flags: FlagsBlurContent | FlagsNoSelf, 65 + }, 66 + intolerant: { 67 + name: `Intolerance`, 68 + flags: FlagsBlurContent | FlagsNoSelf, 69 + }, 70 + threat: { 71 + name: `Threats`, 72 + flags: FlagsBlurContent | FlagsNoSelf, 73 + }, 74 + rude: { 75 + name: `Rude`, 76 + flags: FlagsBlurContent | FlagsNoSelf, 77 + }, 78 + illicit: { 79 + name: `Illicit content`, 80 + flags: FlagsBlurContent | FlagsNoSelf, 81 + }, 82 + security: { 83 + name: `Security risk`, 84 + flags: FlagsBlurContent | FlagsNoSelf, 85 + }, 86 + 'unsafe-link': { 87 + name: `Unsafe link`, 88 + flags: FlagsBlurContent | FlagsNoSelf, 89 + }, 90 + impersonation: { 91 + name: `Impersonation`, 92 + flags: FlagsBlurContent | FlagsNoSelf, 93 + }, 94 + misinformation: { 95 + name: `Misinformation`, 96 + flags: FlagsBlurContent | FlagsNoSelf, 97 + }, 98 + scam: { 99 + name: `Scam`, 100 + flags: FlagsBlurContent | FlagsNoSelf, 101 + }, 102 + 'engagement-farming': { 103 + name: `Engagement farming`, 104 + flags: FlagsBlurContent | FlagsNoSelf, 105 + }, 106 + spam: { 107 + name: `Spam`, 108 + flags: FlagsBlurContent | FlagsNoSelf, 109 + }, 110 + rumor: { 111 + name: `Rumor`, 112 + flags: FlagsBlurContent | FlagsNoSelf, 113 + }, 114 + misleading: { 115 + name: `Misleading`, 116 + flags: FlagsBlurContent | FlagsNoSelf, 117 + }, 118 + inauthentic: { 119 + name: `Inauthentic`, 120 + flags: FlagsBlurContent | FlagsNoSelf, 121 + }, 122 + }; 123 + 124 + export const findLabel = ( 125 + labels: Label[] | undefined, 126 + authorDid: At.DID, 127 + mask: number, 128 + ): LabelDefinition | undefined => { 129 + if (labels?.length) { 130 + for (let idx = 0, len = labels.length; idx < len; idx++) { 131 + const label = labels[idx]; 132 + const val = label.val; 133 + 134 + if (!(val in LABEL_MAPPING)) { 135 + continue; 136 + } 137 + 138 + const def = LABEL_MAPPING[val]; 139 + 140 + if (def.flags & FlagsNoSelf && label.src === authorDid) { 141 + continue; 142 + } 143 + 144 + if (def.flags & mask) { 145 + return def; 146 + } 147 + } 148 + } 149 + };
+14 -2
src/routes/(app)/(profile)/[actor=didOrHandle]/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import { base } from '$app/paths'; 3 - import { formatCompactNumber } from '$lib/utils/intl/number'; 4 3 import type { LayoutProps } from './$types'; 5 4 5 + import { findLabel, FlagsBlurMedia } from '$lib/moderation'; 6 + import { formatCompactNumber } from '$lib/utils/intl/number'; 7 + 6 8 import ProfileAside from './components/profile-aside.svelte'; 7 9 8 10 const { children, data }: LayoutProps = $props(); ··· 11 13 const did = $derived(profile.did); 12 14 13 15 const postCount = $derived(profile.postsCount ?? 0); 16 + const blurBanner = $derived(!!findLabel(profile.labels, profile.did, FlagsBlurMedia)); 14 17 </script> 15 18 16 19 {#key profile.did} 17 20 <div class="profile-layout"> 18 21 <div class="banner"> 19 22 {#if profile.banner} 20 - <img loading="lazy" src={profile.banner} alt="" class="banner-image" /> 23 + <img 24 + loading="lazy" 25 + src={profile.banner} 26 + alt="" 27 + class={['banner-image', blurBanner && 'is-blurred']} 28 + /> 21 29 {/if} 22 30 </div> 23 31 ··· 90 98 width: 100%; 91 99 height: 100%; 92 100 font-size: 0; 101 + } 102 + .is-blurred { 103 + scale: 125%; 104 + filter: blur(24px); 93 105 } 94 106 95 107 .aside {
+5 -2
src/routes/(app)/(profile)/[actor=didOrHandle]/components/profile-aside.svelte
··· 3 3 4 4 import { base } from '$app/paths'; 5 5 6 + import { findLabel, FlagsBlurMedia } from '$lib/moderation'; 6 7 import { formatCompactNumber, formatLongNumber } from '$lib/utils/intl/number'; 7 8 9 + import Avatar from '$lib/components/avatar.svelte'; 8 10 import RichtextRawRenderer from '$lib/components/richtext-raw-renderer.svelte'; 9 - import Avatar from '$lib/components/avatar.svelte'; 10 11 11 12 interface Props { 12 13 profile: AppBskyActorDefs.ProfileViewDetailed; ··· 15 16 const { profile }: Props = $props(); 16 17 17 18 const did = $derived(profile.did); 19 + 20 + const blur = $derived(!!findLabel(profile.labels, profile.did, FlagsBlurMedia)); 18 21 </script> 19 22 20 23 {#snippet Stat(count: number = 0, one: string, many: string, href: string)} ··· 32 35 {/snippet} 33 36 34 37 <div class="profile-aside"> 35 - <Avatar {profile} size="xl" /> 38 + <Avatar {profile} size="xl" {blur} /> 36 39 37 40 <div class="name-wrapper"> 38 41 <p dir="auto" class="display-name">{profile.displayName?.trim() || profile.handle.slice(0, 64)}</p>
+1 -1
src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/main-post.svelte
··· 52 52 <RichTextRenderer text={record.text} facets={record.facets} large /> 53 53 54 54 {#if post.embed} 55 - <Embeds embed={post.embed} large /> 55 + <Embeds {post} embed={post.embed} large /> 56 56 {/if} 57 57 58 58 <div class="footer">
+1 -1
src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/post-ascendant-item.svelte
··· 47 47 <RichtextRenderer text={record.text} facets={record.facets} /> 48 48 49 49 {#if post.embed} 50 - <Embeds embed={post.embed} /> 50 + <Embeds {post} embed={post.embed} /> 51 51 {/if} 52 52 53 53 <PostMetrics {post} />
+1 -1
src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/post-descendant-item.svelte
··· 40 40 <RichtextRenderer text={record.text} facets={record.facets} /> 41 41 42 42 {#if post.embed} 43 - <Embeds embed={post.embed} /> 43 + <Embeds {post} embed={post.embed} /> 44 44 {/if} 45 45 46 46 <PostMetrics {post} />