Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions src/components/text/TextPostCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PATH, MARKETPLACE_CONTRACT_TEIA } from '@constants'
import { HashToURL } from '@utils'
import { useUserStore } from '@context/userStore'
import { useModalStore } from '@context/modalStore'
import { RoleEmoji } from '@components/user-badges'
import styles from './TextPostCard.module.scss'

// Format date to readable string
Expand Down Expand Up @@ -132,16 +133,19 @@ export function TextPostCard({ nft, showBurn = false }) {
<p className={styles.excerpt}>{getExcerpt(description)}</p>
</Link>
<div className={styles.meta}>
<Link
to={
artist_profile?.name
? `/${artist_profile.name}`
: `${PATH.ISSUER}/${artist_address}`
}
className={styles.author_link}
>
{authorName}
</Link>
<span className={styles.author}>
<RoleEmoji address={artist_address} />
<Link
to={
artist_profile?.name
? `/${artist_profile.name}`
: `${PATH.ISSUER}/${artist_address}`
}
className={styles.author_link}
>
{authorName}
</Link>
</span>
<span className={styles.date}>{formatDate(minted_at)}</span>
</div>
</div>
Expand Down
7 changes: 7 additions & 0 deletions src/components/text/TextPostCard.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@
color: var(--secondary-color);
}

// Role emoji + author, kept together so .meta's 16px gap doesn't split them.
.author {
display: inline-flex;
align-items: center;
gap: 6px;
}

.author_link {
font-weight: 500;
color: var(--secondary-color);
Expand Down
16 changes: 10 additions & 6 deletions src/components/text/TokenSearchCommand.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,17 @@ function TokenSearchDropdown({ close, textApi }) {
export const tokenSearchCommand = commands.group([], {
name: 'teia-token',
groupName: 'teia-token',
// The icon alone read as a generic image button, so it carries a label.
icon: (
<svg viewBox="0 0 24 24" width="14" height="14">
<path
fill="currentColor"
d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"
/>
</svg>
<span className={styles.trigger}>
<svg viewBox="0 0 24 24" width="14" height="14">
<path
fill="currentColor"
d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"
/>
</svg>
Embed Token
</span>
),
children: (props) => <TokenSearchDropdown {...props} />,
buttonProps: { 'aria-label': 'Embed Teia Token', title: 'Embed Teia Token' },
Expand Down
9 changes: 9 additions & 0 deletions src/components/text/TokenSearchCommand.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,12 @@
border-radius: 2px;
}
}

// Toolbar trigger: icon plus a visible "Embed Token" label.
.trigger {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
font-size: 12px;
}
51 changes: 51 additions & 0 deletions src/components/user-badges/RoleBadges.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,57 @@ export function RoleBadgesView({ roles, className }) {
)
}

/**
* Roles from most to fewest rights: a multisig core member outranks a
* moderator, who outranks a token holder. Only the highest one is shown.
*/
const ROLE_EMOJI = [
{ key: 'isMultisig', emoji: '📃', label: 'Multisig core member' },
{ key: 'isModerator', emoji: '🛠️', label: 'Moderator' },
{ key: 'isTokenHolder', emoji: '🪙', label: 'TEIA token holder' },
]

/**
* Key to the role emoji, read from the same list the badges use so the two
* cannot drift apart. Listed from fewest to most rights.
*/
export function RoleLegend({ className }) {
return (
<span className={className}>
{[...ROLE_EMOJI].reverse().map(({ key, emoji, label }, i) => (
<span key={key}>
{i > 0 && ' · '}
<span role="img" aria-hidden="true">
{emoji}
</span>{' '}
{label}
</span>
))}
{' — each author shows their highest role.'}
</span>
)
}

/**
* A single emoji for an address's highest role, for placing in front of an
* alias or wallet address. Renders nothing when the address has no role.
*/
export function RoleEmoji({ address, className }) {
const roles = useAccountRoles(address)
const top = ROLE_EMOJI.find(({ key }) => roles[key])
if (!top) return null
return (
<span
className={className}
role="img"
aria-label={top.label}
title={top.label}
>
{top.emoji}
</span>
)
}

/**
* Self-fetching role badges for a single address. Drop in anywhere an account
* is shown, the three role sets are cached app-wide, so this costs no
Expand Down
7 changes: 6 additions & 1 deletion src/components/user-badges/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
export { default as RoleBadges, RoleBadgesView } from './RoleBadges'
export {
default as RoleBadges,
RoleBadgesView,
RoleEmoji,
RoleLegend,
} from './RoleBadges'
131 changes: 0 additions & 131 deletions src/data/swr.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
COPYRIGHT_CONTRACT,
DAO_TREASURY_CONTRACT,
HEN_CONTRACT_FA2,
TEIA_MULTISIG_BLOG_TAG,
BAKING_BAD_BAKERS_API,
} from '@constants'
import {
Expand All @@ -18,8 +17,6 @@ import {
BaseTokenFieldsFragment,
fetchGraphQL,
} from '@data/api'
import laggy from '@utils/swr-laggy-middleware'
import { useModerators } from '@data/roles'
import {
ACTIVITY_EVENT_TYPES,
MINT_TYPES,
Expand Down Expand Up @@ -977,110 +974,6 @@ export function useTopDonors(excludeAddresses = []) {
return { data, mutate, error, isLoading: !data && !error }
}

const TEXT_POSTS_QUERY = gql`
${BaseTokenFieldsFragment}
query TextPosts($limit: Int!) {
tokens(
where: {
_or: [
{ mime_type: { _eq: "text/plain" } }
{ mime_type: { _eq: "text/markdown" } }
]
editions: { _gt: 0 }
metadata_status: { _eq: "processed" }
fa2_address: { _eq: "${HEN_CONTRACT_FA2}" }
}
order_by: { minted_at: desc }
limit: $limit
) {
...baseTokenFields
}
}
`

export function useTextPosts(limit = 100) {
return useSWR(
['text-community'],
() =>
request(import.meta.env.VITE_TEIA_GRAPHQL_API, TEXT_POSTS_QUERY, {
limit,
}),
{
revalidateIfStale: false,
revalidateOnFocus: false,
use: [laggy],
}
)
}

const TEXT_POSTS_BY_ARTIST_QUERY = gql`
${BaseTokenFieldsFragment}
query TextPostsByArtist($address: String!) {
tokens(
where: {
artist_address: { _eq: $address }
_or: [
{ mime_type: { _eq: "text/plain" } }
{ mime_type: { _eq: "text/markdown" } }
]
editions: { _gt: 0 }
metadata_status: { _eq: "processed" }
fa2_address: { _eq: "${HEN_CONTRACT_FA2}" }
}
order_by: { minted_at: desc }
) {
...baseTokenFields
listings(where: { status: { _eq: "active" } }, order_by: { price: asc }) {
seller_address
}
holdings(where: { amount: { _gt: "0" } }) {
holder_address
amount
}
}
}
`

export function useTextPostsByArtist(address) {
return useSWR(
address ? ['text-posts-by-artist', address] : null,
() =>
request(
import.meta.env.VITE_TEIA_GRAPHQL_API,
TEXT_POSTS_BY_ARTIST_QUERY,
{ address }
),
{
revalidateIfStale: false,
revalidateOnFocus: false,
use: [laggy],
}
)
}

const OFFICIAL_TEXT_POSTS_QUERY = gql`
${BaseTokenFieldsFragment}
query OfficialTextPosts($addresses: [String!], $tag: String!, $limit: Int!) {
tokens(
where: {
artist_address: { _in: $addresses }
tags: { tag: { _eq: $tag } }
_or: [
{ mime_type: { _eq: "text/plain" } }
{ mime_type: { _eq: "text/markdown" } }
]
editions: { _gt: 0 }
metadata_status: { _eq: "processed" }
fa2_address: { _eq: "${HEN_CONTRACT_FA2}" }
}
order_by: { minted_at: desc }
limit: $limit
) {
...baseTokenFields
}
}
`

export function useMultisigAddresses() {
const { data } = useSWR(
`/v1/contracts/${DAO_TREASURY_CONTRACT}/storage?path=users`,
Expand All @@ -1090,30 +983,6 @@ export function useMultisigAddresses() {
return data || []
}

export function useOfficialTextPosts(limit = 100) {
const multisigAddresses = useMultisigAddresses()
const { data: moderators } = useModerators()
const addresses = [...new Set([...multisigAddresses, ...(moderators || [])])]
return useSWR(
addresses.length > 0 ? ['text-official', addresses] : null,
() =>
request(
import.meta.env.VITE_TEIA_GRAPHQL_API,
OFFICIAL_TEXT_POSTS_QUERY,
{
addresses,
tag: TEIA_MULTISIG_BLOG_TAG,
limit,
}
),
{
revalidateIfStale: false,
revalidateOnFocus: false,
use: [laggy],
}
)
}

const USER_ACTIVITY_QUERY = gql`
query UserActivity(
$address: String!
Expand Down
81 changes: 81 additions & 0 deletions src/data/text/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SWR hooks for Teia Text feeds.

import useSWR from 'swr'
import { request } from 'graphql-request'
import { TEIA_MULTISIG_BLOG_TAG } from '@constants'
import { useModerators, useTokenHolders } from '@data/roles'
import { useMultisigAddresses } from '@data/swr'
import laggy from '@utils/swr-laggy-middleware'
import {
TEXT_POSTS_QUERY,
TEXT_POSTS_BY_ARTIST_QUERY,
OFFICIAL_TEXT_POSTS_QUERY,
HOLDER_TEXT_POSTS_QUERY,
} from './queries'

const GRAPHQL_API = import.meta.env.VITE_TEIA_GRAPHQL_API

const FEED_OPTIONS = {
revalidateIfStale: false,
revalidateOnFocus: false,
use: [laggy],
}

/** Community feed: the latest posts from anyone. */
export function useTextPosts(limit = 100) {
return useSWR<any>(
['text-community'],
() => request(GRAPHQL_API, TEXT_POSTS_QUERY, { limit }),
FEED_OPTIONS
)
}

/** One author's posts (profile Text tab, Your Posts). */
export function useTextPostsByArtist(address?: string) {
return useSWR<any>(
address ? ['text-posts-by-artist', address] : null,
() => request(GRAPHQL_API, TEXT_POSTS_BY_ARTIST_QUERY, { address }),
FEED_OPTIONS
)
}

/** Bulletin feed: tagged posts from multisig members and moderators. */
export function useOfficialTextPosts(limit = 100) {
const multisigAddresses: string[] = useMultisigAddresses()
const { data: moderators } = useModerators()
const addresses = [...new Set([...multisigAddresses, ...(moderators || [])])]
return useSWR<any>(
addresses.length > 0 ? ['text-official', addresses] : null,
() =>
request(GRAPHQL_API, OFFICIAL_TEXT_POSTS_QUERY, {
addresses,
tag: TEIA_MULTISIG_BLOG_TAG,
limit,
}),
FEED_OPTIONS
)
}

/**
* TEIA Members feed: text posts whose author currently holds TEIA, from the
* same holder set that drives the profile's TEIA HOLDER badge. Filtered
* server-side so the feed gets the latest `limit` holder posts, not just the
* holders among recent posts.
*/
export function useHolderTextPosts(limit = 100) {
const { data: holders, error: holdersError } = useTokenHolders()
const result = useSWR<any>(
// Keyed on the set's size rather than ~2.6k addresses, so SWR isn't hashing
// a huge key on every render.
holders?.size ? ['text-holders', holders.size, limit] : null,
() =>
request(GRAPHQL_API, HOLDER_TEXT_POSTS_QUERY, {
addresses: [...(holders as Set<string>)],
limit,
}),
FEED_OPTIONS
)
const error = result.error || holdersError
// SWR v1 has no `isLoading`: loading until the posts or an error arrive.
return { ...result, error, isLoading: !error && !result.data }
}
2 changes: 2 additions & 0 deletions src/data/text/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './queries'
export * from './hooks'
Loading
Loading