pics timeline

This commit is contained in:
Moon.eth
2026-07-29 11:04:22 +09:00
parent dd88daaf1c
commit 2f0c8821f3
5 changed files with 279 additions and 7 deletions
+120
View File
@@ -0,0 +1,120 @@
<script lang="ts">
/**
* Image attachments from an account timeline, presented like a MySpace photo
* stream. Videos/audio stay in the Blog; reposted pictures are not somebody's
* own Pics.
*/
import type { MediaAttachment, Status } from '$lib/api/types'
import type { Feed } from '$lib/stores/feed.svelte'
import { toPlainText } from '$lib/util/html'
import { stampDate } from '$lib/util/time'
import Pager from '../common/Pager.svelte'
interface Props {
feed: Feed<Status>
ownerName: string
}
interface Picture {
key: string
media: MediaAttachment
status: Status
caption: string
}
let { feed, ownerName }: Props = $props()
let revealed = $state<Record<string, boolean>>({})
const pictures = $derived.by<Picture[]>(() =>
feed.items.flatMap((status) => {
if (status.reblog) return []
const entryCaption = toPlainText(status.content)
return status.media_attachments
.filter((media) => media.type === 'image')
.map((media) => ({
key: `${status.id}:${media.id}`,
media,
status,
caption: media.description?.trim() || entryCaption,
}))
}),
)
function toggle(key: string): void {
revealed = { ...revealed, [key]: !revealed[key] }
}
</script>
<p class="pic-stream-intro">
Pictures from {ownerName}'s Blog Entries. Click a picture to view the full-size original.
</p>
{#if !feed.initialized && feed.loading}
<p class="loading-note">Loading pictures&hellip;</p>
{:else if pictures.length === 0 && feed.items.length > 0}
<p class="empty-note">
No image attachments in the entries loaded so far.
</p>
{:else}
<ul class="pic-stream">
{#each pictures as picture (picture.key)}
{@const hidden = picture.status.sensitive && !revealed[picture.key]}
<li
class="pic-card"
data-status-id={picture.status.id}
data-sensitive={picture.status.sensitive ? 'true' : 'false'}
data-revealed={hidden ? 'false' : 'true'}
>
<figure class="pic-card-figure">
<a
class="pic-card-image-link"
href={picture.media.url}
target="_blank"
rel="noopener noreferrer"
aria-label={hidden ? 'Sensitive picture; reveal it before opening' : 'View full-size picture'}
aria-disabled={hidden ? 'true' : undefined}
onclick={hidden ? (event) => event.preventDefault() : undefined}
>
<img
class="pic-card-image"
src={picture.media.preview_url ?? picture.media.url}
alt={picture.media.description ?? ''}
loading="lazy"
decoding="async"
/>
</a>
{#if picture.status.sensitive}
<button
type="button"
class="button button--small pic-card-reveal"
onclick={() => toggle(picture.key)}
>
{hidden ? 'Show' : 'Hide'} sensitive picture
</button>
{/if}
<figcaption class="pic-card-caption">
{#if hidden}
<span class="pic-card-description">
{picture.status.spoiler_text || 'Sensitive picture'}
</span>
{:else if picture.caption}
<span class="pic-card-description">{picture.caption}</span>
{/if}
<a class="pic-card-entry-link" href={`#/blog/${picture.status.id}`}>
Posted {stampDate(picture.status.created_at)} &middot; view entry
</a>
</figcaption>
</figure>
</li>
{/each}
</ul>
{/if}
<Pager
{feed}
label="View More Pictures"
emptyText={`${ownerName} hasn't posted any pictures yet.`}
endText={pictures.length > 0 ? 'Thats the whole picture stream.' : ''}
/>
+14 -7
View File
@@ -29,6 +29,7 @@
import InterestsTable from '$components/profile/InterestsTable.svelte' import InterestsTable from '$components/profile/InterestsTable.svelte'
import DetailsTable from '$components/profile/DetailsTable.svelte' import DetailsTable from '$components/profile/DetailsTable.svelte'
import FriendSpace from '$components/profile/FriendSpace.svelte' import FriendSpace from '$components/profile/FriendSpace.svelte'
import PicStream from '$components/profile/PicStream.svelte'
import BlogEntry from '$components/blog/BlogEntry.svelte' import BlogEntry from '$components/blog/BlogEntry.svelte'
import Pager from '$components/common/Pager.svelte' import Pager from '$components/common/Pager.svelte'
@@ -107,12 +108,17 @@
entries = new Feed<Status>( entries = new Feed<Status>(
async (cursor) => { async (cursor) => {
const preview = currentView !== 'blog' const preview = currentView === 'profile'
const pictures = currentView === 'pics'
const page = await endpoints.fetchAccountStatuses( const page = await endpoints.fetchAccountStatuses(
session.api, session.api,
found.id, found.id,
cursor, cursor,
preview ? { exclude_replies: true, exclude_reblogs: true } : {}, preview
? { exclude_replies: true, exclude_reblogs: true }
: pictures
? { only_media: true, exclude_reblogs: true }
: {},
) )
// Pleroma-compatible forks do not all honour both exclusion flags. // Pleroma-compatible forks do not all honour both exclusion flags.
@@ -125,7 +131,7 @@
} }
: page : page
}, },
currentView === 'blog' ? 20 : 10, currentView === 'blog' || currentView === 'pics' ? 20 : 10,
) )
void entries.reload() void entries.reload()
@@ -295,10 +301,11 @@
</Module> </Module>
{:else if view === 'pics'} {:else if view === 'pics'}
<Module title={`${firstName}'s Pics`} variant="band"> <Module title={`${firstName}'s Pics`} variant="band">
<p class="empty-note"> {#snippet action()}
Photos appear here as they're attached to blog entries. <a href={base}>[Back to Profile]</a>
<a href={`#/@${account.acct}/blog`}>Read the blog</a> to see them in context. {/snippet}
</p>
<PicStream feed={entries} ownerName={firstName} />
</Module> </Module>
{:else} {:else}
<!-- <!--
+73
View File
@@ -66,4 +66,77 @@ describe('Profile', () => {
expect(view.queryByText('Someone elses note')).not.toBeInTheDocument() expect(view.queryByText('Someone elses note')).not.toBeInTheDocument()
expect(view.queryByText('A reply')).not.toBeInTheDocument() expect(view.queryByText('A reply')).not.toBeInTheDocument()
}) })
it('builds Pics from the accounts own image attachments', async () => {
const ownPicture = status({
id: 'picture-entry',
content: '<p>A day at the beach</p>',
media_attachments: [
{
id: 'picture',
type: 'image',
url: 'https://media.example/beach.jpg',
preview_url: 'https://media.example/beach-small.jpg',
description: 'Sunset over the beach',
},
],
})
const video = status({
id: 'video-entry',
content: '<p>A video that does not belong in Pics</p>',
media_attachments: [
{
id: 'video',
type: 'video',
url: 'https://media.example/movie.mp4',
preview_url: 'https://media.example/movie.jpg',
description: 'A movie',
},
],
})
const repostedPicture = status({
id: 'repost',
reblog: status({
id: 'someone-elses-picture',
content: '<p>Someone elses picture</p>',
media_attachments: [
{
id: 'reposted-picture',
type: 'image',
url: 'https://media.example/reposted.jpg',
preview_url: null,
description: 'A reposted picture',
},
],
}),
})
const fetchAccountStatuses = vi.fn().mockResolvedValue({
items: [ownPicture, video, repostedPicture],
links: {},
})
const services = testServices({
session: session(),
theme: theme(),
endpoints: {
lookupAccount: vi.fn().mockResolvedValue(account()),
fetchAccountStatuses,
},
})
const view = render(Profile, {
props: { acct: 'alice', view: 'pics' },
context: new Map([[APP_SERVICES, services]]),
})
expect(await view.findByRole('img', { name: 'Sunset over the beach' })).toHaveAttribute(
'src',
'https://media.example/beach-small.jpg',
)
expect(fetchAccountStatuses.mock.calls[0][3]).toEqual({
only_media: true,
exclude_reblogs: true,
})
expect(view.getByText('Sunset over the beach')).toBeInTheDocument()
expect(view.queryByText('A movie')).not.toBeInTheDocument()
expect(view.queryByText('A reposted picture')).not.toBeInTheDocument()
})
}) })
+1
View File
@@ -51,6 +51,7 @@
['.profile-vitals', 'Gender / age / location / last active'], ['.profile-vitals', 'Gender / age / location / last active'],
['.interests-table, .interests-label, .interests-value', 'The Interests table'], ['.interests-table, .interests-label, .interests-value', 'The Interests table'],
['.friend-grid, .friend-card, .friend-card-photo', 'Friend Space'], ['.friend-grid, .friend-card, .friend-card-photo', 'Friend Space'],
['.pic-stream, .pic-card, .pic-card-image', 'Pics stream'],
], ],
}, },
{ {
+71
View File
@@ -232,6 +232,73 @@
white-space: nowrap; white-space: nowrap;
} }
/* ------------------------------------------------------------- pics stream */
.pic-stream-intro {
margin: 0 0 10px;
color: var(--ms-muted-fg);
font-size: var(--ms-font-size);
}
.pic-stream {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.pic-card {
min-width: 0;
border: 1px solid var(--ms-module-border);
background: var(--ms-table-stripe-bg);
padding: 6px;
}
.pic-card-figure {
margin: 0;
}
.pic-card-image-link {
display: block;
border: 1px solid var(--ms-avatar-border);
background: var(--ms-canvas-bg);
}
.pic-card-image {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: contain;
}
.pic-card[data-sensitive='true'][data-revealed='false'] .pic-card-image {
filter: blur(18px);
}
.pic-card-reveal {
display: block;
width: 100%;
margin-top: 4px;
}
.pic-card-caption {
padding: 6px 2px 1px;
font-size: var(--ms-font-size-small);
}
.pic-card-description {
display: block;
margin-bottom: 4px;
color: var(--ms-page-fg);
overflow-wrap: anywhere;
}
.pic-card-entry-link {
display: block;
}
/* A richer row used on the full friends list and search results. */ /* A richer row used on the full friends list and search results. */
.person-list { .person-list {
list-style: none; list-style: none;
@@ -359,4 +426,8 @@
max-width: 220px; max-width: 220px;
margin: 0 auto; margin: 0 auto;
} }
.pic-stream {
grid-template-columns: minmax(0, 1fr);
}
} }