OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / app / Screens.jsx

479 lines · jsx ref:
// The ported pages. favela-web serves `/`, `/post/<uri>`, `/profile/<did>`, search and settings as
// SSR HTML; these are the same questions asked of the same appview, rendered natively.
//
// Every screen takes `client` and a `nav` object. There is deliberately NO navigation library: the
// stack here is one level deep from a timeline, and react-navigation would be another native dependency
// on an APK that already carries 40 addons. `nav.push/pop` over a state array is the whole requirement.
import React, { useEffect, useState, useCallback } from 'react'
import { View, Text, TextInput, FlatList, Pressable, ActivityIndicator, RefreshControl, StyleSheet, ScrollView } from 'react-native'
import { BlobImage } from './Post'
import Post from './Post'
import RichText from './RichText'
import { displayName, shortDid, relTime } from '../lib/richtext'

// ── shared bits ──────────────────────────────────────────────────────────────────────────────────────
export const Center = ({ children }) => <View style={s.center}>{children}</View>

export function Header ({ title, onBack, right, onTitlePress }) {
  return (
    <View style={s.header}>
      {onBack ? <Pressable onPress={onBack} hitSlop={10}><Text style={s.back}>‹</Text></Pressable> : null}
      {onTitlePress
        ? <Pressable onPress={onTitlePress} hitSlop={8}><Text style={s.title} numberOfLines={1}>{title} <Text style={s.titleChevron}>⌄</Text></Text></Pressable>
        : <Text style={s.title} numberOfLines={1}>{title}</Text>}
      <View style={{ flex: 1 }} />
      {right}
    </View>
  )
}

// Every identity this node hosts. The daemon has always served the user's own DID plus each co-owned
// community; this is the first surface that admits it exists. Selecting one switches what the timeline
// shows and which identity reads are answered as.
//
// `signing` is shown because it is the difference between an identity you can post as and one you can
// only read — a co-owned DID this device has not been admitted to reads fine and cannot author, and a
// UI that hid that would present a compose box whose writes silently go nowhere.
// One person. Rendered from a hydrated profile rather than a bare DID — `follows`/`followers` used to
// return DID strings, which a UI can only show as hex, which is why there was no way to browse anyone.
export function PersonRow ({ client, person, onOpen }) {
  return (
    <Pressable style={s.row} onPress={() => onOpen && onOpen(person.did)}>
      {person.avatar
        ? <BlobImage client={client} did={person.did} imgRef={person.avatar.ref || person.avatar} style={s.avatar} />
        : <View style={[s.avatar, s.avatarBlank]} />}
      <View style={{ flex: 1, marginLeft: 10 }}>
        <Text style={s.rowT} numberOfLines={1}>{person.displayName || person.handle || shortDid(person.did)}</Text>
        {person.handle ? <Text style={s.dim} numberOfLines={1}>@{person.handle}</Text> : <Text style={s.dim} numberOfLines={1}>{shortDid(person.did)}</Text>}
        {person.description ? <Text style={s.dim} numberOfLines={2}>{person.description}</Text> : null}
      </View>
    </Pressable>
  )
}

// The read surface lives in favela-web now. A screen must NOT silently fall back to the worklet's own
// appview: that is a SECOND index over the same records, so a fallback would quietly show a different
// answer to the same question. If favela-web is down, say so — the node log names the reason.
export function requireApi (api) {
  if (!api) throw new Error('favela-web is not running — the node log says why')
  return api
}

// Followers / following as a browsable list.
export function People ({ client, api, did, kind, nav }) {
  // hydrate=1 gets profiles in the SAME request; the fallback is the legacy verb, for a phone whose
  // favela-web did not start.
  const { loading, error, data, reload } = useAsync(() => requireApi(api).people({ did, kind }), [api, did, kind])
  if (loading || error) return <Loading error={error} onRetry={reload} />
  const people = (data && data.people) || []
  if (!people.length) return <Center><Text style={s.dim}>no {kind} held for this account yet</Text></Center>
  return (
    <FlatList
      style={s.list}
      data={people}
      keyExtractor={(p) => p.did}
      renderItem={({ item }) => <PersonRow client={client} person={item} onOpen={(d) => nav.push({ name: 'profile', did: d })} />}
    />
  )
}

export function Identities ({ client, current, onPick }) {
  const { loading, error, data } = useAsync(() => client.hostedDids(), [client])
  if (loading) return <Center><ActivityIndicator /></Center>
  if (error) return <Center><Text style={s.err}>{error}</Text></Center>
  const dids = (data && data.dids) || []
  if (!dids.length) return <Center><Text style={s.dim}>this node hosts no identity yet — pair it first</Text></Center>
  return (
    <FlatList
      style={s.list}
      data={dids}
      keyExtractor={(d) => d.did}
      renderItem={({ item }) => (
        <Pressable style={s.row} onPress={() => onPick && onPick(item)}>
          <View style={{ flex: 1 }}>
            <Text style={s.rowT}>
              {item.handle || item.did}
              {item.primary ? <Text style={s.dim}>  you</Text> : null}
              {current && current.did === item.did ? <Text style={s.link}>  ✓</Text> : null}
            </Text>
            <Text style={s.dim} numberOfLines={1}>{item.did}</Text>
            <Text style={s.dim}>{item.signing ? 'can post' : 'read-only here — not admitted as a writer'}</Text>
          </View>
        </Pressable>
      )}
    />
  )
}

// Communities and accounts this node does NOT host but can READ over P2P. Hosting is about keys and
// signing; this is about looking — and only the first existed, so a community you had not joined was
// not "empty", it was absent. Adding one joins its topic, verifies its head against PLC, and indexes it
// into the same appview, after which it behaves like any other account in the app.
// Accounts this node has already SEEN — hosted identities and anyone in the local index. Typing a
// 32-character DID on a phone keyboard is not a real way to reach a community, and it was the only way.
function Suggestions ({ client, onPick }) {
  const { data } = useAsync(async () => {
    const [hosted, tracked] = await Promise.all([
      client.hostedDids().catch(() => ({ dids: [] })),
      client.trackedDids().catch(() => ({ dids: [] }))
    ])
    const seen = new Set((tracked.dids || []).map((d) => d.did))
    return (hosted.dids || []).filter((d) => !seen.has(d.did))
  }, [client])
  const list = data || []
  if (!list.length) return null
  return (
    <View style={s.sugRow}>
      {list.map((d) => (
        <Pressable key={d.did} style={s.chip} onPress={() => onPick(d.did)}>
          <Text style={s.chipT} numberOfLines={1}>{d.handle || shortDid(d.did)}</Text>
        </Pressable>
      ))}
    </View>
  )
}

export function Communities ({ client, nav }) {
  const [did, setDid] = useState('')
  const [busy, setBusy] = useState(false)
  const [err, setErr] = useState(null)
  const { loading, error, data, reload } = useAsync(() => client.trackedDids(), [client])

  const [status, setStatus] = useState(null)
  // NEVER RETURN SILENTLY. This began as `if (!target) return`, so pressing `view` with an empty field
  // did nothing at all — no error, no spinner, no log — and an empty field is easy to arrive at because
  // the input is CONTROLLED: anything that does not go through onChangeText (a synthetic event, a
  // restored view) leaves text on screen that the component does not have. Silence made a wiring bug
  // and a network failure indistinguishable, and cost an evening.
  const add = async (value) => {
    const target = String(value != null ? value : did).trim()
    if (!target) { setErr('paste a DID first — nothing to view'); return }
    if (!target.startsWith('did:')) { setErr('that is not a DID (expected did:plc:… )'); return }
    setBusy(true); setErr(null); setStatus('joining ' + target.slice(0, 24) + '… — finding someone serving it')
    try {
      const r = await client.viewDid({ did: target })
      setStatus('indexed ' + (r.indexed != null ? r.indexed + ' records' : 'it') + (r.rev ? ' at ' + r.rev : ''))
      setDid('')
      await reload()
    } catch (e) { setErr(e.message); setStatus(null) } finally { setBusy(false) }
  }

  return (
    <View style={s.fill}>
      <View style={s.addRow}>
        <TextInput
          style={s.input}
          value={did}
          onChangeText={setDid}
          placeholder="did:plc:… of a community or account"
          autoCapitalize="none"
          autoCorrect={false}
          returnKeyType="go"
          onSubmitEditing={() => add()}
        />
        <Pressable style={[s.btn, busy && s.btnOff]} disabled={busy} onPress={() => add()}><Text style={s.btnT}>{busy ? '…' : 'view'}</Text></Pressable>
      </View>
      {err ? <Text style={s.err}>{err}</Text> : null}
      {status && !err ? <Text style={s.dim}>{status}</Text> : null}
      <Suggestions client={client} onPick={(d) => add(d)} />
      {loading || error
        ? <Loading error={error} onRetry={reload} />
        : <FlatList
            style={s.list}
            data={(data && data.dids) || []}
            keyExtractor={(d) => d.did}
            ListEmptyComponent={<Center><Text style={s.dim}>nothing tracked yet — paste a DID above</Text></Center>}
            renderItem={({ item }) => <PersonRow client={client} person={item} onOpen={(d) => nav.push({ name: 'profile', did: d })} />}
          />}
    </View>
  )
}

function useAsync (fn, deps) {
  const [state, setState] = useState({ loading: true, error: null, data: null })
  const run = useCallback(async () => {
    setState((p) => ({ ...p, loading: true, error: null }))
    try { setState({ loading: false, error: null, data: await fn() }) } catch (e) { setState({ loading: false, error: e.message, data: null }) }
  }, deps) // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => { run() }, [run])
  return { ...state, reload: run }
}

function Loading ({ error, onRetry }) {
  if (error) {
    return <Center><Text style={s.err}>{error}</Text>{onRetry ? <Pressable onPress={onRetry}><Text style={s.link}>retry</Text></Pressable> : null}</Center>
  }
  return <Center><ActivityIndicator /></Center>
}

// ── thread ───────────────────────────────────────────────────────────────────────────────────────────
// A post and its replies. The web page renders ancestors + the post + descendants; the appview's
// getPostThread returns exactly that tree, so this walks it rather than re-deriving structure.
export function Thread ({ client, api, uri, nav }) {
  const { loading, error, data, reload } = useAsync(async () => {
    const src = requireApi(api)
    const [{ thread }, { replies }] = await Promise.all([src.thread({ uri }), src.replies({ uri })])
    return { thread, replies }
  }, [uri, api])

  if (loading || error) return <Loading error={error} onRetry={reload} />
  const root = data.thread && (data.thread.post || data.thread)
  return (
    <FlatList
      style={s.list}
      data={data.replies || []}
      keyExtractor={(p, i) => p.uri || String(i)}
      ListHeaderComponent={
        <View>
          {root ? <Post post={root} client={client} onProfile={(did) => nav.push({ name: 'profile', did })} onTag={(t) => nav.push({ name: 'tag', tag: t })} /> : <Center><Text style={s.dim}>post not held locally</Text></Center>}
          <Text style={s.section}>{(data.replies || []).length} repl{(data.replies || []).length === 1 ? 'y' : 'ies'}</Text>
        </View>
      }
      renderItem={({ item }) => (
        <Post post={item} client={client} onOpen={(p) => nav.push({ name: 'thread', uri: p.uri || uri })} onProfile={(did) => nav.push({ name: 'profile', did })} />
      )}
      ListEmptyComponent={<Center><Text style={s.dim}>no replies</Text></Center>}
    />
  )
}

// ── profile ──────────────────────────────────────────────────────────────────────────────────────────
export function Profile ({ client, api, did, nav }) {
  const [busy, setBusy] = useState(false)
  const { loading, error, data, reload } = useAsync(async () => {
    const src = requireApi(api)
    const [{ profile }, feed] = await Promise.all([src.profile({ did }), src.author({ did, limit: 30 })])
    return { profile, posts: feed.posts || [] }
  }, [did, api])

  const toggleFollow = async () => {
    setBusy(true)
    try { await client.follow({ did }); await reload() } catch (e) { /* surfaced on reload */ } finally { setBusy(false) }
  }

  if (loading || error) return <Loading error={error} onRetry={reload} />
  const p = data.profile || {}
  return (
    <FlatList
      style={s.list}
      data={data.posts}
      keyExtractor={(x, i) => x.uri || String(i)}
      ListHeaderComponent={
        <View style={s.profileHead}>
          <Text style={s.pName}>{p.displayName || shortDid(did)}</Text>
          {p.handle ? <Text style={s.pHandle}>@{p.handle}</Text> : null}
          {p.description ? <Text style={s.pDesc}>{p.description}</Text> : null}
          <Text style={s.dim}>{shortDid(did)}</Text>
          <Pressable style={[s.btn, busy && s.btnOff]} disabled={busy} onPress={toggleFollow}>
            <Text style={s.btnT}>{busy ? '…' : 'follow / unfollow'}</Text>
          </Pressable>
          <View style={s.peopleLinks}>
            <Pressable onPress={() => nav.push({ name: 'people', did, kind: 'follows' })}><Text style={s.link}>following</Text></Pressable>
            <Pressable onPress={() => nav.push({ name: 'people', did, kind: 'followers' })}><Text style={s.link}>followers</Text></Pressable>
          </View>
        </View>
      }
      renderItem={({ item }) => <Post post={item} client={client} onOpen={(x) => nav.push({ name: 'thread', uri: x.uri })} onProfile={(d) => nav.push({ name: 'profile', did: d })} />}
      ListEmptyComponent={<Center><Text style={s.dim}>no posts held for this account</Text></Center>}
    />
  )
}

// ── search ───────────────────────────────────────────────────────────────────────────────────────────
export function Search ({ client, api, nav }) {
  const [q, setQ] = useState('')
  const [kind, setKind] = useState('posts')
  const [res, setRes] = useState(null)
  const [busy, setBusy] = useState(false)

  const go = async () => {
    if (!q.trim()) return
    setBusy(true)
    try { setRes(await requireApi(api).search({ q: q.trim(), kind })) } catch (e) { setRes({ error: e.message }) } finally { setBusy(false) }
  }

  return (
    <View style={{ flex: 1 }}>
      <View style={s.searchBar}>
        <TextInput style={s.input} value={q} onChangeText={setQ} placeholder="search" autoCapitalize="none" onSubmitEditing={go} returnKeyType="search" />
        <Pressable onPress={() => setKind(kind === 'posts' ? 'actors' : 'posts')}><Text style={s.link}>{kind}</Text></Pressable>
      </View>
      {busy ? <Center><ActivityIndicator /></Center> : null}
      {res && res.error ? <Center><Text style={s.err}>{res.error}</Text></Center> : null}
      {res && res.truncated ? <Text style={s.dim}>  scan capped — results are partial</Text> : null}
      <FlatList
        data={kind === 'actors' ? (res && res.actors) || [] : (res && res.posts) || []}
        keyExtractor={(x, i) => x.uri || x.did || String(i)}
        renderItem={({ item }) => (kind === 'actors'
          ? <Pressable style={s.actorRow} onPress={() => nav.push({ name: 'profile', did: item.did })}>
              <Text style={s.name}>{item.displayName || shortDid(item.did)}</Text>
              {item.handle ? <Text style={s.dim}>@{item.handle}</Text> : null}
            </Pressable>
          : <Post post={item} client={client} onOpen={(x) => nav.push({ name: 'thread', uri: x.uri })} onProfile={(d) => nav.push({ name: 'profile', did: d })} />)}
      />
    </View>
  )
}

// ── compose ──────────────────────────────────────────────────────────────────────────────────────────
// The 300-grapheme cap is enforced in the Writer (favela-web/lib/writer.js) — this shows the count so
// the limit is visible before the write fails, but does not re-implement the rule.
export function Compose ({ client, replyTo = null, onDone }) {
  const [text, setText] = useState('')
  const [busy, setBusy] = useState(false)
  const [err, setErr] = useState(null)
  const graphemes = [...text].length

  const send = async () => {
    setBusy(true); setErr(null)
    try { await client.compose({ text, replyTo }); onDone && onDone() } catch (e) { setErr(e.message) } finally { setBusy(false) }
  }

  return (
    <ScrollView contentContainerStyle={s.compose}>
      <TextInput
        style={[s.input, s.composeBox]}
        value={text}
        onChangeText={setText}
        placeholder={replyTo ? 'reply…' : "what's happening"}
        multiline
        autoFocus
      />
      <View style={s.composeFoot}>
        <Text style={[s.dim, graphemes > 300 && s.err]}>{graphemes}/300</Text>
        <View style={{ flex: 1 }} />
        <Pressable style={[s.btn, (!text.trim() || busy || graphemes > 300) && s.btnOff]} disabled={!text.trim() || busy || graphemes > 300} onPress={send}>
          {busy ? <ActivityIndicator color="#fff" /> : <Text style={s.btnT}>post</Text>}
        </Pressable>
      </View>
      {err ? <Text style={s.err}>{err}</Text> : null}
    </ScrollView>
  )
}

// ── settings / devices ───────────────────────────────────────────────────────────────────────────────
export function Settings ({ client, status, nav }) {
  const { loading, error, data, reload } = useAsync(async () => client.devices(), [])
  return (
    <ScrollView contentContainerStyle={s.settings}>
      <Text style={s.section}>this node</Text>
      <Text style={s.kv}>did      {status && status.did ? shortDid(status.did) : '—'}</Text>
      <Text style={s.kv}>signing  {status && status.signing ? 'yes' : 'no (read-only)'}</Text>
      <Text style={s.kv}>storage  {status && status.storage}</Text>

      {status && status.signing
        ? <Pressable onPress={() => nav && nav.push({ name: 'invite' })}><Text style={[s.link, { paddingHorizontal: 14, marginTop: 14 }]}>invite another device ›</Text></Pressable>
        : null}

      <Text style={s.section}>delegated devices</Text>
      {loading ? <ActivityIndicator /> : null}
      {error ? <Text style={s.err}>{error}</Text> : null}
      {(data && data.devices ? data.devices : []).map((d) => (
        <View key={d.subKey} style={s.deviceRow}>
          <Text style={s.mono} numberOfLines={1}>{String(d.subKey).slice(0, 20)}…</Text>
          <Text style={s.dim}>{d.revoked ? 'REVOKED' : 'active'}{d.label ? ' · ' + d.label : ''}</Text>
          <Pressable onPress={async () => { await client.revokeDevice({ subKey: d.subKey, restore: !!d.revoked }); reload() }}>
            <Text style={s.link}>{d.revoked ? 'restore' : 'revoke'}</Text>
          </Pressable>
        </View>
      ))}
      {data && data.devices && !data.devices.length ? <Text style={s.dim}>no browsers paired to this identity</Text> : null}
    </ScrollView>
  )
}


// ── invite a device ──────────────────────────────────────────────────────────────────────────────────
// The phone as INVITER. Symmetric with the desktop by design: keet's `addDevice` is available to any
// active member and gives the adder nothing, and coownerInvite no longer claims the genesis — so there
// is no reason a phone should only ever be the joiner.
//
// ⛔ THE WARNING IS THE APPROVAL. The bundle this mints carries the shared signing key and mutates the
// roster, so mintAdoptInvite takes an `approve` callback that must never fire speculatively. Tapping the
// button behind this text IS that approval, which is why the text says what it grants before the code
// appears.
export function Invite ({ client }) {
  const [code, setCode] = useState(null)
  const [busy, setBusy] = useState(false)
  const [err, setErr] = useState(null)
  const [step, setStep] = useState(null)

  useEffect(() => {
    const onLog = (m) => { const t = m.msg || String(m); if (t.startsWith('invite:')) setStep(t.slice(7).trim()) }
    client.on('log', onLog)
    return () => { if (typeof client.off === 'function') client.off('log', onLog) }
  }, [client])

  const mint = async () => {
    setBusy(true); setErr(null)
    try { const r = await client.invite(); setCode(r.inviteHex); setStep('waiting for the other device…') } catch (e) { setErr(e.message) } finally { setBusy(false) }
  }

  return (
    <ScrollView contentContainerStyle={s.settings}>
      <Text style={s.section}>invite a device</Text>
      <View style={s.warnBox}>
        <Text style={s.warnB}>
          The code below hands another device the key that signs your posts. After that it can post as
          you, on its own, offline. Anyone who reads the code before the other device uses it can take
          your identity — it is single-use and short-lived, but treat it like a password.
        </Text>
      </View>
      {code
        ? <View>
            <Text style={s.codeBox} selectable>{code}</Text>
            <Text style={s.dim}>{step || 'waiting for the other device…'}</Text>
          </View>
        : <Pressable style={[s.btn, busy && s.btnOff]} disabled={busy} onPress={mint}>
            {busy ? <ActivityIndicator color="#fff" /> : <Text style={s.btnT}>generate an invite</Text>}
          </Pressable>}
      {err ? <Text style={s.err}>{err}</Text> : null}
    </ScrollView>
  )
}

const s = StyleSheet.create({
  titleChevron: { fontSize: 14, color: '#657786' },
  avatar: { width: 44, height: 44, borderRadius: 22, backgroundColor: '#e6ecf0' },
  avatarBlank: {},
  addRow: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12 },
  sugRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, paddingHorizontal: 12, paddingBottom: 8 },
  chip: { backgroundColor: '#e8f1f8', borderRadius: 14, paddingHorizontal: 12, paddingVertical: 7 },
  chipT: { color: '#1b6ec2', fontSize: 13, maxWidth: 220 },
  peopleLinks: { flexDirection: 'row', gap: 18, marginTop: 10 },
  row: { paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e6ecf0', flexDirection: 'row', alignItems: 'center' },
  rowT: { fontSize: 15, fontWeight: '600', color: '#14171a' },
  list: { flex: 1, backgroundColor: '#fff' },
  center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 8 },
  header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#ddd' },
  back: { fontSize: 28, lineHeight: 30, color: '#06c' },
  title: { fontWeight: '700', fontSize: 17 },
  section: { fontWeight: '700', marginTop: 18, marginBottom: 6, paddingHorizontal: 14 },
  dim: { color: '#888', fontSize: 13 },
  err: { color: '#b00' },
  link: { color: '#06c' },
  name: { fontWeight: '600' },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 6, padding: 10, fontSize: 15, flex: 1 },
  searchBar: { flexDirection: 'row', alignItems: 'center', gap: 10, padding: 12 },
  actorRow: { padding: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
  profileHead: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#ddd', gap: 4 },
  pName: { fontSize: 20, fontWeight: '700' },
  pHandle: { color: '#666' },
  pDesc: { marginTop: 6, fontSize: 15, lineHeight: 20 },
  btn: { backgroundColor: '#111', borderRadius: 8, paddingVertical: 10, paddingHorizontal: 16, alignItems: 'center', marginTop: 12, alignSelf: 'flex-start' },
  btnOff: { opacity: 0.4 },
  btnT: { color: '#fff', fontWeight: '600' },
  compose: { padding: 16, gap: 12 },
  composeBox: { minHeight: 140, textAlignVertical: 'top' },
  composeFoot: { flexDirection: 'row', alignItems: 'center' },
  settings: { padding: 14, paddingBottom: 40 },
  kv: { fontFamily: 'monospace', fontSize: 13, color: '#333', paddingHorizontal: 14 },
  mono: { fontFamily: 'monospace', fontSize: 12, flex: 1 },
  deviceRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: 8 },
  warnBox: { backgroundColor: '#fff6e5', borderColor: '#e8c98a', borderWidth: 1, borderRadius: 8, padding: 14, marginHorizontal: 14, marginBottom: 12 },
  warnB: { fontSize: 14, lineHeight: 20, color: '#4a3c1a' },
  codeBox: { fontFamily: 'monospace', fontSize: 12, backgroundColor: '#f2f2f2', padding: 12, borderRadius: 6, marginHorizontal: 14 },
  btn: { backgroundColor: '#111', borderRadius: 8, paddingVertical: 12, alignItems: 'center', marginHorizontal: 14 },
  btnOff: { opacity: 0.4 },
  btnT: { color: '#fff', fontWeight: '600' }
})