~james/core

core/web/src/lib/components/timeline/TimelineView.svelte -rw-r--r-- 6.4 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
<script lang="ts">
	import { resolve } from "$app/paths";
	import { navigating, page } from "$app/state";
	import GalleryVertical from "$icon/gallery-vertical";
	import Globe from "$icon/globe";
	import UserRoundGroup from "$icon/user-round-group";
	import Button from "#lib/components/ui/Button.svelte";
	import ErrorAlert from "#lib/components/ui/Error.svelte";
	import RefreshCw from "$icon/refresh-cw";
	import Segment from "#lib/components/ui/Segment.svelte";
	import SegmentedControl from "#lib/components/ui/SegmentedControl.svelte";
	import Announcements from "#lib/components/timeline/Announcements.svelte";
	import NewsletterCard from "#lib/components/timeline/NewsletterCard.svelte";
	import NotificationsPreview from "#lib/components/timeline/NotificationsPreview.svelte";
	import Pending from "#lib/components/ui/Pending.svelte";
	import RecentsPreview from "#lib/components/timeline/RecentsPreview.svelte";
	import Timeline from "#lib/components/timeline/Timeline.svelte";
	import TrendingRepos from "#lib/components/timeline/TrendingRepos.svelte";
	import Bones from "#lib/components/ui/Bones.svelte";
	import timelineTimelineSkeletonFixture from "#lib/bones/timeline-timeline--skeleton-fixture.bones.json";
	import type { RepoCardData } from "#lib/components/profile/types.ts";
	import OnboardingNudge from "#lib/components/welcome/OnboardingNudge.svelte";
	import type { NotificationSummary } from "#lib/components/notifications/types.ts";
	import type { BlogPost } from "#lib/components/timeline/types.ts";
	import type * as OrgTangledFeedGetTimeline from "#lib/api/lexicons/types/org/tangled/feed/getTimeline.ts";
	import { loadRecents } from "#lib/components/timeline/recents.svelte.ts";

	let {
		feed,
		trending = [],
		announcements = [],
		notifications,
		followingOnly = false,
		signedIn = false,
		onboardingNudge = false,
		basePath,
		onRefresh
	}: {
		feed?:
			| OrgTangledFeedGetTimeline.$output["feed"]
			| Promise<OrgTangledFeedGetTimeline.$output["feed"]>;
		trending?: RepoCardData[] | Promise<RepoCardData[]>;
		announcements?: BlogPost[] | Promise<BlogPost[]>;
		notifications?: NotificationSummary[] | Promise<NotificationSummary[]>;
		followingOnly?: boolean;
		signedIn?: boolean;
		onboardingNudge?: boolean;
		/** route the global/following toggle stays on, e.g. "/" or "/timeline" */
		basePath: string;
		onRefresh?: () => void;
	} = $props();

	// loads after hydration, the server can't see this browser's storage
	const recents = loadRecents();

	const pendingFollowing = $derived(
		navigating.to && navigating.to.url.pathname === page.url.pathname
			? navigating.to.url.searchParams.get("following") === "true"
			: undefined
	);
	const switching = $derived(
		pendingFollowing !== undefined && pendingFollowing !== followingOnly
	);

	const toggleHref = (following: boolean) =>
		resolve((following ? `${basePath}?following=true` : basePath) as "/");

	const refreshIfActive = (following: boolean) => (event: MouseEvent) => {
		if (following !== followingOnly || !onRefresh) return;
		if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
			return;
		event.preventDefault();
		onRefresh();
	};
</script>

{#snippet findPeople()}
	<Button href={toggleHref(false)} icon={Globe}>Find people on the Global timeline</Button>
{/snippet}

<div
	class="mx-auto flex w-full max-w-[1920px] flex-col md:grid md:grid-cols-[minmax(0,25rem)_minmax(0,37.5rem)_minmax(0,25rem)] md:items-start md:justify-center"
>
	<div
		class="flex flex-col md:sticky md:top-0 md:col-start-1 md:row-start-2 md:h-screen md:w-full md:max-w-100 md:gap-md md:overflow-y-auto md:p-md"
	>
		{#if signedIn && onboardingNudge}
			<OnboardingNudge class="max-md:px-xs max-md:pt-md" />
		{/if}
		<div class="contents max-md:hidden">
			<NotificationsPreview {signedIn} {notifications} />
			<RecentsPreview items={recents.items} />
		</div>
	</div>

	<div
		class="order-2 flex flex-col gap-md px-xs pt-2xl md:order-0 md:col-start-2 md:row-start-1 md:mx-auto md:w-full md:max-w-150 md:px-none"
	>
		<div class="flex items-center justify-between">
			<h1 class="flex items-center gap-xs pl-xs typography-heading-4">
				<GalleryVertical class="size-lg" aria-hidden="true" />
				<span class="max-[24rem]:sr-only">Timeline</span>
			</h1>
			{#if signedIn}
				<SegmentedControl
					value={followingOnly ? "following" : "global"}
					href={(value) => toggleHref(value === "following")}
					label="Timeline"
				>
					<Segment value="global" onclick={refreshIfActive(false)} icon={Globe}>
						Global
					</Segment>
					<Segment
						value="following"
						onclick={refreshIfActive(true)}
						icon={UserRoundGroup}
					>
						Following
					</Segment>
				</SegmentedControl>
			{/if}
		</div>
	</div>

	<div
		class="order-3 flex flex-col px-xs pt-md pb-2xl md:order-0 md:col-start-2 md:row-start-2 md:mx-auto md:w-full md:max-w-150 md:px-none"
	>
		{#if feed === undefined || switching}
			<Bones capture={timelineTimelineSkeletonFixture} />
		{:else if feed instanceof Promise}
			<Pending key={feed}>
				{#snippet skeleton()}
					<Bones capture={timelineTimelineSkeletonFixture} />
				{/snippet}
				{#snippet failed()}
					<div class="flex flex-col items-start gap-sm" data-testid="timelineError">
						<ErrorAlert
							class="self-stretch"
							label={followingOnly
								? "Could not load the timeline of people you follow."
								: "Could not load the timeline."}
						/>
						{#if onRefresh}
							<Button icon={RefreshCw} onclick={onRefresh}>Try again</Button>
						{/if}
					</div>
				{/snippet}
				{@const resolvedFeed = await feed}
				<Timeline
					feed={resolvedFeed}
					{signedIn}
					emptyMessage={followingOnly
						? "Activity from people you follow shows up here."
						: "Nothing here yet."}
					emptyAction={followingOnly ? findPeople : undefined}
				/>
			</Pending>
		{:else}
			<Timeline
				{feed}
				{signedIn}
				emptyMessage={followingOnly
					? "Activity from people you follow shows up here."
					: "Nothing here yet."}
				emptyAction={followingOnly ? findPeople : undefined}
			/>
		{/if}
	</div>

	<!-- right rail: leads on mobile, where only trending shows -->
	<div
		class="order-1 flex flex-col gap-md px-xs pt-md md:sticky md:top-0 md:order-0 md:col-start-3 md:row-start-2 md:ml-auto md:h-screen md:w-full md:max-w-100 md:overflow-y-auto md:p-md"
	>
		<TrendingRepos repos={trending} />
		{#if signedIn}
			<NewsletterCard />
		{/if}

		<div class="hidden md:block">
			<Announcements posts={announcements} />
		</div>
	</div>
</div>