mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-14 03:02:31 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Contacting Tom" — the two-column action list.
|
||||
*
|
||||
* Follow state comes from `/api/v1/accounts/relationships`, which is only
|
||||
* available when signed in; logged out, the actions become sign-in prompts
|
||||
* rather than disappearing, so the box keeps its shape.
|
||||
*/
|
||||
import type { Account, Relationship } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { blockAccount, followAccount, unblockAccount, unfollowAccount } from '$lib/api/endpoints'
|
||||
import { displayNameOf } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
account: Account
|
||||
relationship: Relationship | null
|
||||
onrelationship?: (relationship: Relationship) => void
|
||||
}
|
||||
|
||||
let { account, relationship, onrelationship }: Props = $props()
|
||||
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const firstName = $derived(displayNameOf(account).split(/\s+/)[0])
|
||||
const isSelf = $derived(session.me?.id === account.id)
|
||||
const following = $derived(relationship?.following ?? false)
|
||||
const requested = $derived(relationship?.requested ?? false)
|
||||
const blocking = $derived(relationship?.blocking ?? false)
|
||||
|
||||
const followLabel = $derived(
|
||||
blocking
|
||||
? 'Unblock User'
|
||||
: following
|
||||
? 'Remove from Friends'
|
||||
: requested
|
||||
? 'Cancel Friend Request'
|
||||
: account.locked
|
||||
? 'Request to Add'
|
||||
: 'Add to Friends',
|
||||
)
|
||||
|
||||
async function run(action: () => Promise<Relationship>): Promise<void> {
|
||||
if (busy) return
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
onrelationship?.(await action())
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'That didn’t work.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFollow(): void {
|
||||
if (blocking) {
|
||||
void run(() => unblockAccount(session.api, account.id))
|
||||
} else if (following || requested) {
|
||||
void run(() => unfollowAccount(session.api, account.id))
|
||||
} else {
|
||||
void run(() => followAccount(session.api, account.id))
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBlock(): void {
|
||||
void run(() =>
|
||||
blocking ? unblockAccount(session.api, account.id) : blockAccount(session.api, account.id),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Module title={`Contacting ${firstName}`}>
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<ul class="action-list">
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">✉</span>
|
||||
{#if session.signedIn}
|
||||
<a class="action-list-label" href={`#/compose?to=${encodeURIComponent(account.acct)}`}>Send Message</a>
|
||||
{:else}
|
||||
<a class="action-list-label" href="#/login">Send Message</a>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">👤</span>
|
||||
{#if isSelf}
|
||||
<span class="action-list-label muted">This is you</span>
|
||||
{:else if session.signedIn}
|
||||
<button
|
||||
type="button"
|
||||
class="link-button action-list-label"
|
||||
disabled={busy}
|
||||
aria-pressed={following ? 'true' : 'false'}
|
||||
onclick={toggleFollow}
|
||||
>
|
||||
{followLabel}
|
||||
</button>
|
||||
{:else}
|
||||
<a class="action-list-label" href="#/login">Add to Friends</a>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">★</span>
|
||||
<a class="action-list-label" href={account.url} target="_blank" rel="noopener noreferrer">
|
||||
View on {account.acct.includes('@') ? account.acct.split('@')[1] : session.host}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">➦</span>
|
||||
<a class="action-list-label" href={`#/@${account.acct}/friends`}>Forward to Friend</a>
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">💬</span>
|
||||
<a class="action-list-label" href={`#/@${account.acct}/blog`}>Instant Message</a>
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">⛔</span>
|
||||
{#if isSelf || !session.signedIn}
|
||||
<span class="action-list-label muted">Block User</span>
|
||||
{:else}
|
||||
<button type="button" class="link-button action-list-label" disabled={busy} onclick={toggleBlock}>
|
||||
{blocking ? 'Unblock User' : 'Block User'}
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{#if relationship?.followed_by && !isSelf}
|
||||
<p class="contact-note muted">{firstName} has you on their friends list.</p>
|
||||
{/if}
|
||||
</Module>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Tom's Details" — profile fields that didn't map to an interests row.
|
||||
*
|
||||
* Mastodon's link verification is surfaced here: a field whose URL proved
|
||||
* ownership is highlighted, matching what every other client does, because
|
||||
* an unverified link that looks verified is a phishing surface.
|
||||
*/
|
||||
import type { ProfileField } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
fields: ProfileField[]
|
||||
}
|
||||
|
||||
let { title, fields }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if fields.length > 0}
|
||||
<Module {title} flush>
|
||||
<table class="data-table details-table">
|
||||
<tbody>
|
||||
{#each fields as field (field.name)}
|
||||
<tr class="details-row" data-verified={field.verified ? 'true' : 'false'}>
|
||||
<th class="data-table-label details-label" scope="row">{field.name}</th>
|
||||
<td class="data-table-value details-value" data-verified={field.verified ? 'true' : 'false'}>
|
||||
{#if field.verified}
|
||||
<span class="verified-mark" title="Ownership of this link is verified">✓</span>
|
||||
{/if}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
|
||||
{@html field.value}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Module>
|
||||
{/if}
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Tom's Friend Space" — the grid of tiny avatars with names above them.
|
||||
*
|
||||
* Mastodon lets an account hide its follower/following lists, and remote
|
||||
* accounts often return an empty list rather than an error, so the count and
|
||||
* the grid are allowed to disagree; the count is authoritative.
|
||||
*/
|
||||
import type { Account } from '$lib/api/types'
|
||||
import { displayNameOf, formatCount, profilePath } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
/** The subject, used in "Tom has 527 friends." */
|
||||
ownerName: string
|
||||
friends: Account[]
|
||||
total: number
|
||||
viewAllHref: string
|
||||
loading?: boolean
|
||||
/** The list is withheld by the account's privacy settings. */
|
||||
hidden?: boolean
|
||||
/** The count is withheld too, so `total` is not meaningful. */
|
||||
countHidden?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
ownerName,
|
||||
friends,
|
||||
total,
|
||||
viewAllHref,
|
||||
loading = false,
|
||||
hidden = false,
|
||||
countHidden = false,
|
||||
compact = false,
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<Module {title} variant="band">
|
||||
{#snippet action()}
|
||||
<a href={viewAllHref}>[view all]</a>
|
||||
{/snippet}
|
||||
|
||||
<!-- Never render a withheld count as "0 friends" — that reports a privacy
|
||||
setting as a fact about the person. -->
|
||||
{#if countHidden}
|
||||
<p class="friend-count">{ownerName} keeps their friend count private.</p>
|
||||
{:else}
|
||||
<p class="friend-count">
|
||||
{ownerName} has <span class="friend-count-value">{formatCount(total)}</span>
|
||||
friend{total === 1 ? '' : 's'}.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if hidden}
|
||||
<p class="empty-note">This friends list is private.</p>
|
||||
{:else if loading && friends.length === 0}
|
||||
<p class="loading-note">Loading friends…</p>
|
||||
{:else if friends.length === 0}
|
||||
<p class="empty-note">No friends to show yet.</p>
|
||||
{:else}
|
||||
<ul class="friend-grid" class:friend-grid--compact={compact}>
|
||||
{#each friends as friend (friend.id)}
|
||||
<li class="friend-card" data-account={friend.acct}>
|
||||
<a class="friend-card-link" href={profilePath(friend)}>
|
||||
<span class="friend-card-name">{displayNameOf(friend)}</span>
|
||||
<img
|
||||
class="friend-card-photo"
|
||||
src={friend.avatar_static || friend.avatar}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Tom's Interests" — the label/value table.
|
||||
*
|
||||
* Rows only appear when the account has a matching profile field, so a bare
|
||||
* Mastodon account gets a compact box rather than six empty rows.
|
||||
*/
|
||||
import type { InterestEntry } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
interests: InterestEntry[]
|
||||
}
|
||||
|
||||
let { title, interests }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if interests.length > 0}
|
||||
<Module {title} flush>
|
||||
<table class="data-table interests-table">
|
||||
<tbody>
|
||||
{#each interests as entry (entry.row)}
|
||||
<tr class="interests-row" data-row={entry.row.toLowerCase()}>
|
||||
<th class="data-table-label interests-label" scope="row">{entry.row}</th>
|
||||
<td class="data-table-value interests-value">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
|
||||
{@html entry.value}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Module>
|
||||
{/if}
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The photo-and-vitals block at the top of a profile.
|
||||
*
|
||||
* MySpace showed gender, age, location and last-login as bare lines beside
|
||||
* the photo. Mastodon publishes none of those, so the ones a user hasn't put
|
||||
* in a profile field are simply omitted — except "age", which falls back to
|
||||
* how long the account has existed, because a profile with no numbers at all
|
||||
* doesn't read as a profile.
|
||||
*/
|
||||
import type { ProfileView } from '$lib/util/profile'
|
||||
import { displayNameOf, fullHandle } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { relativeTime, shortDate, yearsSince } from '$lib/util/time'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
|
||||
interface Props {
|
||||
profile: ProfileView
|
||||
}
|
||||
|
||||
let { profile }: Props = $props()
|
||||
|
||||
const account = $derived(profile.account)
|
||||
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
||||
const handle = $derived(fullHandle(account, session.host))
|
||||
const accountAge = $derived(profile.age ?? yearsSince(account.created_at))
|
||||
const photo = $derived(account.avatar || account.avatar_static)
|
||||
</script>
|
||||
|
||||
<div class="profile-identity">
|
||||
<div class="profile-photo-wrap">
|
||||
{#if photo}
|
||||
<a class="profile-photo-link" href={account.url} target="_blank" rel="noopener noreferrer">
|
||||
<img class="profile-photo" src={photo} alt={displayNameOf(account)} decoding="async" />
|
||||
</a>
|
||||
{:else}
|
||||
<span class="profile-photo profile-photo--empty" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<a class="profile-photo-caption" href={`#/@${account.acct}/pics`}>View more pics</a>
|
||||
</div>
|
||||
|
||||
<div class="profile-vitals-wrap">
|
||||
<p class="profile-headline">{profile.headline}</p>
|
||||
|
||||
<dl class="profile-vitals">
|
||||
{#if profile.gender}
|
||||
<dt>Gender</dt>
|
||||
<dd class="profile-vital profile-vital--gender">{profile.gender}</dd>
|
||||
{/if}
|
||||
|
||||
{#if accountAge !== null}
|
||||
<dt>Age</dt>
|
||||
<dd class="profile-vital profile-vital--age">
|
||||
{accountAge} years old
|
||||
{#if profile.age === null}
|
||||
<span class="muted">(on this server)</span>
|
||||
{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
|
||||
{#if profile.location}
|
||||
<dt>Location</dt>
|
||||
<dd class="profile-vital profile-vital--location">{profile.location}</dd>
|
||||
{/if}
|
||||
|
||||
<dt>Last active</dt>
|
||||
<dd class="profile-vital profile-vital--active">
|
||||
Last active:<br />
|
||||
{account.last_status_at ? relativeTime(account.last_status_at) : 'unknown'}
|
||||
</dd>
|
||||
|
||||
<dt>Member since</dt>
|
||||
<dd class="profile-vital profile-vital--joined">
|
||||
Member since: {shortDate(account.created_at)}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{#if profile.mood}
|
||||
<p class="profile-mood">
|
||||
Mood: <span class="profile-mood-value">{profile.mood}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="profile-viewlinks">
|
||||
<span class="profile-viewlinks-label">View my:</span>
|
||||
<a href={`#/@${account.acct}/blog`}>Blog</a>
|
||||
|
|
||||
<a href={`#/@${account.acct}/friends`}>Friends</a>
|
||||
|
|
||||
<a href={`#/@${account.acct}/pics`}>Pics</a>
|
||||
</p>
|
||||
|
||||
<p class="profile-handle-line">
|
||||
<span class="visually-hidden">Handle:</span>
|
||||
<code class="profile-handle">{handle}</code>
|
||||
{#if account.bot}
|
||||
<span class="profile-badge" data-badge="bot">bot</span>
|
||||
{/if}
|
||||
{#if account.locked}
|
||||
<span class="profile-badge" data-badge="locked">private</span>
|
||||
{/if}
|
||||
{#each account.roles ?? [] as role (role.id)}
|
||||
<span class="profile-badge" data-badge="role">{role.name}</span>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user