mirror of
https://github.com/coracle-social/flotilla.git
synced 2025-12-10 02:47:06 +00:00
Get chat view started
This commit is contained in:
@@ -79,3 +79,7 @@
|
||||
.input input::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.shadow-top-xl {
|
||||
@apply shadow-[0_20px_25px_-5px_rgb(0,0,0,0.1)_0_8px_10px_-6px_rgb(0,0,0,0.1)];
|
||||
}
|
||||
|
||||
48
src/app/components/GroupNote.svelte
Normal file
48
src/app/components/GroupNote.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import {readable} from 'svelte/store'
|
||||
import type {CustomEvent} from '@welshman/util'
|
||||
import {GROUP_REPLY, getAncestorTags, displayProfile, displayPubkey} from '@welshman/util'
|
||||
import {deriveEvent} from '@welshman/store'
|
||||
import {fly} from '@lib/transition'
|
||||
import Icon from '@lib/components/Icon.svelte'
|
||||
import Avatar from '@lib/components/Avatar.svelte'
|
||||
import {repository} from '@app/base'
|
||||
import {deriveProfile} from '@app/state'
|
||||
|
||||
export let event: CustomEvent
|
||||
export let showPubkey: boolean
|
||||
|
||||
const profile = deriveProfile(event.pubkey)
|
||||
const {replies} = getAncestorTags(event.tags)
|
||||
const parentEvent = replies.length > 0
|
||||
? deriveEvent(repository, replies[0][1])
|
||||
: readable(null)
|
||||
|
||||
$: parentProfile = deriveProfile($parentEvent?.pubkey)
|
||||
</script>
|
||||
|
||||
<div in:fly>
|
||||
{#if event.kind === GROUP_REPLY}
|
||||
<div class="pl-12">
|
||||
<div class="text-xs flex gap-1">
|
||||
<Icon icon="arrow-right" />
|
||||
<Avatar src={$parentProfile?.picture} size={4}/>
|
||||
<p class="text-primary">{displayProfile($parentProfile, displayPubkey($parentEvent.pubkey))}<p>
|
||||
<p class="whitespace-nowrap overflow-hidden text-ellipsis">{$parentEvent.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
{#if showPubkey}
|
||||
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={10} />
|
||||
{:else}
|
||||
<div class="w-10" />
|
||||
{/if}
|
||||
<div class="-mt-1">
|
||||
{#if showPubkey}
|
||||
<strong class="text-sm text-primary">{displayProfile($profile, displayPubkey(event.pubkey))}</strong>
|
||||
{/if}
|
||||
<p class="text-sm">{event.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,6 +13,7 @@
|
||||
import {quintOut} from 'svelte/easing'
|
||||
import {identity, nth} from '@welshman/lib'
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
import SpaceAdd from '@app/components/SpaceAdd.svelte'
|
||||
import {session} from "@app/base"
|
||||
@@ -53,13 +54,7 @@
|
||||
<div class="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<PrimaryNavItem on:click={gotoHome}>
|
||||
<div class="!flex w-10 items-center justify-center rounded-full border border-solid border-base-300">
|
||||
{#if $userProfile?.picture}
|
||||
<img alt="" src={$userProfile.picture} />
|
||||
{:else}
|
||||
<Icon icon="user-rounded" size={7} />
|
||||
{/if}
|
||||
</div>
|
||||
<Avatar src={$userProfile?.picture} class="border border-solid border-base-300 !w-10 !h-10" size={7} />
|
||||
</PrimaryNavItem>
|
||||
{#each $userGroupsByNom.entries() as [nom, qualifiedGroups] (nom)}
|
||||
{@const qualifiedGroup = qualifiedGroups[0]}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type {Readable} from "svelte/store"
|
||||
import type {FuseResult} from 'fuse.js'
|
||||
import {get, writable, readable, derived} from "svelte/store"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {uniq, uniqBy, groupBy, pushToMapKey, nthEq, batcher, postJson, stripProtocol, assoc, indexBy, now} from "@welshman/lib"
|
||||
import {getIdentifier, getRelayTags, getRelayTagValues, normalizeRelayUrl, getPubkeyTagValues, GROUP_META, PROFILE, RELAYS, FOLLOWS, MUTES, GROUPS, getGroupTags, readProfile, readList, asDecryptedEvent, editList, makeList, createList} from "@welshman/util"
|
||||
import {max, uniq, between, uniqBy, groupBy, pushToMapKey, nthEq, batcher, postJson, stripProtocol, assoc, indexBy, now} from "@welshman/lib"
|
||||
import {getIdentifier, getRelayTags, getRelayTagValues, normalizeRelayUrl, getPubkeyTagValues, GROUP_META, PROFILE, RELAYS, FOLLOWS, MUTES, GROUPS, getGroupTags, readProfile, readList, asDecryptedEvent, editList, makeList, createList, GROUP_JOIN, GROUP_ADD_USER} from "@welshman/util"
|
||||
import type {Filter, SignedEvent, CustomEvent, PublishedProfile, PublishedList} from '@welshman/util'
|
||||
import type {SubscribeRequest, PublishRequest} from '@welshman/net'
|
||||
import {publish as basePublish, subscribe} from '@welshman/net'
|
||||
@@ -75,16 +75,16 @@ export const publish = (request: PublishRequest) => {
|
||||
}
|
||||
|
||||
export const load = (request: SubscribeRequest) =>
|
||||
new Promise<Maybe<CustomEvent>>(resolve => {
|
||||
new Promise<CustomEvent[]>(resolve => {
|
||||
const sub = subscribe({closeOnEose: true, timeout: 3000, delay: 50, ...request})
|
||||
const events: CustomEvent[] = []
|
||||
|
||||
sub.emitter.on('event', (url: string, e: SignedEvent) => {
|
||||
repository.publish(e)
|
||||
sub.close()
|
||||
resolve(e)
|
||||
events.push(e)
|
||||
})
|
||||
|
||||
sub.emitter.on('complete', () => resolve(undefined))
|
||||
sub.emitter.on('complete', () => resolve(events))
|
||||
})
|
||||
|
||||
// Freshness
|
||||
@@ -448,7 +448,7 @@ export const groupMemberships = deriveEventsMapped<PublishedGroupMembership>(rep
|
||||
|
||||
export const {
|
||||
indexStore: groupMembershipByPubkey,
|
||||
getIndex: getGroupMembersipByPubkey,
|
||||
getIndex: getGroupMembershipsByPubkey,
|
||||
deriveItem: deriveGroupMembership,
|
||||
loadItem: loadGroupMembership,
|
||||
} = createCollection({
|
||||
@@ -473,7 +473,7 @@ export type GroupMessage = {
|
||||
export const readGroupMessage = (event: CustomEvent): Maybe<GroupMessage> => {
|
||||
const nom = event.tags.find(nthEq(0, 'h'))?.[1]
|
||||
|
||||
if (!nom) {
|
||||
if (!nom || between(GROUP_ADD_USER - 1, GROUP_JOIN + 1, event.kind)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -501,7 +501,7 @@ export const groupConversations = derived(groupMessages, $groupMessages => {
|
||||
|
||||
export const {
|
||||
indexStore: groupConversationByNom,
|
||||
getIndex: getGroupMembersipByNom,
|
||||
getIndex: getGroupConversationsByNom,
|
||||
deriveItem: deriveGroupConversation,
|
||||
loadItem: loadGroupConversation,
|
||||
} = createCollection({
|
||||
@@ -510,12 +510,15 @@ export const {
|
||||
getKey: groupConversation => groupConversation.nom,
|
||||
load: (nom: string, hints = [], request: Partial<SubscribeRequest> = {}) => {
|
||||
const relays = [...hints, ...get(relayUrlsByNom).get(nom) || []]
|
||||
const conversation = get(groupConversations).find(c => c.nom === nom)
|
||||
const timestamps = conversation?.messages.map(m => m.event.created_at) || []
|
||||
const since = Math.min(0, max(timestamps) - 3600)
|
||||
|
||||
if (relays.length === 0) {
|
||||
console.warn(`Attempted to load conversation for ${nom} with no qualified groups`)
|
||||
}
|
||||
|
||||
return load({...request, relays, filters: [{'#h': [nom]}]})
|
||||
return load({...request, relays, filters: [{'#h': [nom], since}]})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
3
src/assets/icons/Arrow Right.svg
Normal file
3
src/assets/icons/Arrow Right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 12H20M20 12L14 6M20 12L14 18" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 230 B |
17
src/lib/components/Avatar.svelte
Normal file
17
src/lib/components/Avatar.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import cx from 'classnames'
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
||||
export let src
|
||||
export let size = 7
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cx($$props.class, "!flex items-center justify-center rounded-full overflow-hidden")}
|
||||
style={`width: ${size * 4}px; height: ${size * 4}px;`}>
|
||||
{#if src}
|
||||
<img alt="" {src} />
|
||||
{:else}
|
||||
<Icon icon="user-rounded" size={Math.round(size * .7)} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -12,6 +12,7 @@
|
||||
import AddCircle from "@assets/icons/Add Circle.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/Alt Arrow Right.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/Alt Arrow Left.svg?dataurl"
|
||||
import ArrowRight from "@assets/icons/Arrow Right.svg?dataurl"
|
||||
import CheckCircle from "@assets/icons/Check Circle.svg?dataurl"
|
||||
import ClipboardText from "@assets/icons/Clipboard Text.svg?dataurl"
|
||||
import CloseCircle from "@assets/icons/Close Circle.svg?dataurl"
|
||||
@@ -48,6 +49,7 @@
|
||||
"add-circle": AddCircle,
|
||||
"alt-arrow-right": AltArrowRight,
|
||||
"alt-arrow-left": AltArrowLeft,
|
||||
"arrow-right": ArrowRight,
|
||||
"check-circle": CheckCircle,
|
||||
"clipboard-text": ClipboardText,
|
||||
"close-circle": CloseCircle,
|
||||
|
||||
@@ -60,3 +60,33 @@ export const createSearch = <V, T>(data: T[], opts: SearchOptions<V, T>) => {
|
||||
searchValues: (term: string) => search(term).map(opts.getValue),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const secondsToDate = (ts: number) => new Date(ts * 1000)
|
||||
|
||||
export const dateToSeconds = (date: Date) => Math.round(date.valueOf() / 1000)
|
||||
|
||||
export const getTimeZone = () => new Date().toString().match(/GMT[^\s]+/)
|
||||
|
||||
export const createLocalDate = (dateString: any) => new Date(`${dateString} ${getTimeZone()}`)
|
||||
|
||||
export const getLocale = () => new Intl.DateTimeFormat().resolvedOptions().locale
|
||||
|
||||
export const formatTimestamp = (ts: number) => {
|
||||
const formatter = new Intl.DateTimeFormat(getLocale(), {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
})
|
||||
|
||||
return formatter.format(secondsToDate(ts))
|
||||
}
|
||||
|
||||
export const formatTimestampAsDate = (ts: number) => {
|
||||
const formatter = new Intl.DateTimeFormat(getLocale(), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
|
||||
return formatter.format(secondsToDate(ts))
|
||||
}
|
||||
|
||||
@@ -1,16 +1,88 @@
|
||||
<script lang="ts">
|
||||
import {sleep, sortBy} from '@welshman/lib'
|
||||
import type {CustomEvent} from '@welshman/util'
|
||||
import {page} from '$app/stores'
|
||||
import {fly} from '@lib/transition'
|
||||
import {formatTimestampAsDate} from '@lib/util'
|
||||
import Spinner from '@lib/components/Spinner.svelte'
|
||||
import Avatar from '@lib/components/Avatar.svelte'
|
||||
import GroupNote from '@app/components/GroupNote.svelte'
|
||||
import {deriveGroup, deriveGroupConversation} from '@app/state'
|
||||
|
||||
const group = deriveGroup($page.params.nom)
|
||||
const conversation = deriveGroupConversation($page.params.nom)
|
||||
|
||||
const assertEvent = (e: any) => e as CustomEvent
|
||||
|
||||
type Element = {
|
||||
id: string
|
||||
type: 'date' | 'note',
|
||||
value: string | CustomEvent
|
||||
showPubkey: boolean
|
||||
}
|
||||
|
||||
let loading = true
|
||||
let elements: Element[] = []
|
||||
|
||||
$: {
|
||||
elements = []
|
||||
|
||||
let previousDate
|
||||
let previousPubkey
|
||||
|
||||
for (const {event} of sortBy(m => m.event.created_at, $conversation?.messages || [])) {
|
||||
const {id, kind, pubkey, created_at} = event
|
||||
const date = formatTimestampAsDate(created_at)
|
||||
|
||||
if (date !== previousDate) {
|
||||
elements.push({type: 'date', value: date, id: date, showPubkey: false})
|
||||
}
|
||||
|
||||
elements.push({
|
||||
id,
|
||||
type: 'note',
|
||||
value: event,
|
||||
showPubkey: date !== previousDate || previousPubkey !== pubkey,
|
||||
})
|
||||
|
||||
previousDate = date
|
||||
previousPubkey = pubkey
|
||||
}
|
||||
|
||||
elements.reverse()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
loading = false
|
||||
}, 3000)
|
||||
</script>
|
||||
|
||||
<div class="h-screen flex flex-col">
|
||||
<div class="min-h-32 bg-base-100">
|
||||
<div class="min-h-24 bg-base-100 shadow-xl">
|
||||
</div>
|
||||
<div class="flex-grow overflow-auto">
|
||||
<div class="flex-grow overflow-auto flex flex-col-reverse gap-2 p-2">
|
||||
{#each elements as {type, id, value, showPubkey} (id)}
|
||||
{#if type === "date"}
|
||||
<div class="flex gap-2 items-center text-xs opacity-50">
|
||||
<div class="h-px flex-grow bg-base-content" />
|
||||
<p>{value}</p>
|
||||
<div class="h-px flex-grow bg-base-content" />
|
||||
</div>
|
||||
<div class="min-h-32 bg-base-100">
|
||||
{:else}
|
||||
<GroupNote event={assertEvent(value)} {showPubkey} />
|
||||
{/if}
|
||||
{/each}
|
||||
<p class="flex justify-center items-center py-20 h-10">
|
||||
<Spinner {loading}>
|
||||
{#if loading}
|
||||
Looking for messages...
|
||||
{:else}
|
||||
End of message history
|
||||
{/if}
|
||||
</Spinner>
|
||||
</p>
|
||||
</div>
|
||||
<div class="min-h-32 bg-base-100 shadow-top-xl">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
1
src/routes/spaces/[nom]/+page.ts
Normal file
1
src/routes/spaces/[nom]/+page.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = false
|
||||
Reference in New Issue
Block a user