mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
pics timeline
This commit is contained in:
@@ -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…</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)} · 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 ? 'That’s the whole picture stream.' : ''}
|
||||
/>
|
||||
@@ -29,6 +29,7 @@
|
||||
import InterestsTable from '$components/profile/InterestsTable.svelte'
|
||||
import DetailsTable from '$components/profile/DetailsTable.svelte'
|
||||
import FriendSpace from '$components/profile/FriendSpace.svelte'
|
||||
import PicStream from '$components/profile/PicStream.svelte'
|
||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||
import Pager from '$components/common/Pager.svelte'
|
||||
|
||||
@@ -107,12 +108,17 @@
|
||||
|
||||
entries = new Feed<Status>(
|
||||
async (cursor) => {
|
||||
const preview = currentView !== 'blog'
|
||||
const preview = currentView === 'profile'
|
||||
const pictures = currentView === 'pics'
|
||||
const page = await endpoints.fetchAccountStatuses(
|
||||
session.api,
|
||||
found.id,
|
||||
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.
|
||||
@@ -125,7 +131,7 @@
|
||||
}
|
||||
: page
|
||||
},
|
||||
currentView === 'blog' ? 20 : 10,
|
||||
currentView === 'blog' || currentView === 'pics' ? 20 : 10,
|
||||
)
|
||||
void entries.reload()
|
||||
|
||||
@@ -295,10 +301,11 @@
|
||||
</Module>
|
||||
{:else if view === 'pics'}
|
||||
<Module title={`${firstName}'s Pics`} variant="band">
|
||||
<p class="empty-note">
|
||||
Photos appear here as they're attached to blog entries.
|
||||
<a href={`#/@${account.acct}/blog`}>Read the blog</a> to see them in context.
|
||||
</p>
|
||||
{#snippet action()}
|
||||
<a href={base}>[Back to Profile]</a>
|
||||
{/snippet}
|
||||
|
||||
<PicStream feed={entries} ownerName={firstName} />
|
||||
</Module>
|
||||
{:else}
|
||||
<!--
|
||||
|
||||
@@ -66,4 +66,77 @@ describe('Profile', () => {
|
||||
expect(view.queryByText('Someone else’s note')).not.toBeInTheDocument()
|
||||
expect(view.queryByText('A reply')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('builds Pics from the account’s 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 else’s 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
['.profile-vitals', 'Gender / age / location / last active'],
|
||||
['.interests-table, .interests-label, .interests-value', 'The Interests table'],
|
||||
['.friend-grid, .friend-card, .friend-card-photo', 'Friend Space'],
|
||||
['.pic-stream, .pic-card, .pic-card-image', 'Pics stream'],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -232,6 +232,73 @@
|
||||
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. */
|
||||
.person-list {
|
||||
list-style: none;
|
||||
@@ -359,4 +426,8 @@
|
||||
max-width: 220px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.pic-stream {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user