mirror of
https://github.com/zedeus/nitter
synced 2026-09-05 14:49:32 +00:00
parent
17ad0fec34
commit
db5a229bfe
11 changed files with 379 additions and 3 deletions
13
src/api.nim
13
src/api.nim
|
|
@ -88,6 +88,19 @@ proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} =
|
||||||
result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr(
|
result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr(
|
||||||
streamJs{"source", "location"}.getStr)
|
streamJs{"source", "location"}.getStr)
|
||||||
|
|
||||||
|
proc getAudioSpace*(id: string): Future[AudioSpace] {.async.} =
|
||||||
|
if id.len == 0: return
|
||||||
|
let
|
||||||
|
variables = %*{
|
||||||
|
"id": id,
|
||||||
|
"isMetatagsQuery": false,
|
||||||
|
"withReplays": true,
|
||||||
|
"withListeners": true
|
||||||
|
}
|
||||||
|
req = apiReq(graphAudioSpace, $variables)
|
||||||
|
js = await fetch(req)
|
||||||
|
result = parseAudioSpace(js)
|
||||||
|
|
||||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const
|
||||||
graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds"
|
graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds"
|
||||||
|
|
||||||
graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery"
|
graphBroadcast* = "FJLCzpXCLPM1jUZqmM7oEA/BroadcastQuery"
|
||||||
|
graphAudioSpace* = "rWRLsOhNJ2xjpI1tREYurQ/AudioSpaceById"
|
||||||
restLiveStream* = "1.1/live_video_stream/status/"
|
restLiveStream* = "1.1/live_video_stream/status/"
|
||||||
|
|
||||||
gqlFeatures* = """{
|
gqlFeatures* = """{
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
|
||||||
import views/[general, about]
|
import views/[general, about]
|
||||||
import routes/[
|
import routes/[
|
||||||
preferences, timeline, status, media, search, rss, list, community, debug,
|
preferences, timeline, status, media, search, rss, list, community, debug,
|
||||||
unsupported, embed, resolver, broadcast, article, router_utils]
|
unsupported, embed, resolver, broadcast, space, article, router_utils]
|
||||||
|
|
||||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||||
|
|
@ -65,6 +65,7 @@ createMediaRouter(cfg)
|
||||||
createEmbedRouter(cfg)
|
createEmbedRouter(cfg)
|
||||||
createRssRouter(cfg)
|
createRssRouter(cfg)
|
||||||
createBroadcastRouter(cfg)
|
createBroadcastRouter(cfg)
|
||||||
|
createSpaceRouter(cfg)
|
||||||
createDebugRouter(cfg)
|
createDebugRouter(cfg)
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
|
@ -136,5 +137,6 @@ routes:
|
||||||
extend resolver, ""
|
extend resolver, ""
|
||||||
extend embed, ""
|
extend embed, ""
|
||||||
extend broadcastRoute, ""
|
extend broadcastRoute, ""
|
||||||
|
extend spaceRoute, ""
|
||||||
extend debug, ""
|
extend debug, ""
|
||||||
extend unsupported, ""
|
extend unsupported, ""
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,51 @@ proc parseBroadcastInfo*(js: JsonNode): Broadcast =
|
||||||
user: parseGraphUser(bc)
|
user: parseGraphUser(bc)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
proc parseSpaceParticipant(js: JsonNode): SpaceParticipant =
|
||||||
|
result = SpaceParticipant(
|
||||||
|
userId: js{"user_results", "rest_id"}.getStr,
|
||||||
|
username: js{"twitter_screen_name"}.getStr,
|
||||||
|
displayName: js{"display_name"}.getStr,
|
||||||
|
avatarUrl: js{"avatar_url"}.getStr,
|
||||||
|
isVerified: js{"is_verified"}.getBool or
|
||||||
|
js{"user_results", "result", "is_blue_verified"}.getBool
|
||||||
|
)
|
||||||
|
|
||||||
|
proc parseAudioSpace*(js: JsonNode): AudioSpace =
|
||||||
|
let space = ? js{"data", "audioSpace"}
|
||||||
|
let meta = space{"metadata"}
|
||||||
|
|
||||||
|
result = AudioSpace(
|
||||||
|
id: meta{"rest_id"}.getStr,
|
||||||
|
title: meta{"title"}.getStr,
|
||||||
|
state: meta{"state"}.getStr.toUpperAscii,
|
||||||
|
mediaKey: meta{"media_key"}.getStr,
|
||||||
|
totalLiveListeners: meta{"total_live_listeners"}.getInt,
|
||||||
|
totalReplayWatched: meta{"total_replay_watched"}.getInt,
|
||||||
|
availableForReplay: meta{"is_space_available_for_replay"}.getBool
|
||||||
|
)
|
||||||
|
|
||||||
|
let startedAt = meta{"started_at"}.getInt(0)
|
||||||
|
if startedAt > 0:
|
||||||
|
result.startTime = fromUnix(startedAt div 1000).utc()
|
||||||
|
|
||||||
|
let endedAtStr = meta{"ended_at"}.getStr
|
||||||
|
if endedAtStr.len > 0:
|
||||||
|
try:
|
||||||
|
let endedAt = parseBiggestInt(endedAtStr)
|
||||||
|
if endedAt > 0:
|
||||||
|
result.endTime = fromUnix(endedAt div 1000).utc()
|
||||||
|
except ValueError:
|
||||||
|
discard
|
||||||
|
|
||||||
|
result.creator = parseGraphUser(meta{"creator_results", "result"})
|
||||||
|
|
||||||
|
for admin in space{"participants", "admins"}:
|
||||||
|
result.admins.add parseSpaceParticipant(admin)
|
||||||
|
|
||||||
|
for speaker in space{"participants", "speakers"}:
|
||||||
|
result.speakers.add parseSpaceParticipant(speaker)
|
||||||
|
|
||||||
proc parseGraphCommunity*(js: JsonNode): Community =
|
proc parseGraphCommunity*(js: JsonNode): Community =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
let c = ? js{"data", "communityResults", "result"}
|
let c = ? js{"data", "communityResults", "result"}
|
||||||
|
|
@ -390,7 +435,13 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||||
result.url = vals{"player_url"}.getStrVal
|
result.url = vals{"player_url"}.getStrVal
|
||||||
if "youtube.com" in result.url:
|
if "youtube.com" in result.url:
|
||||||
result.url = result.url.replace("/embed/", "/watch?v=")
|
result.url = result.url.replace("/embed/", "/watch?v=")
|
||||||
of audiospace, unknown:
|
of audiospace:
|
||||||
|
let spaceId = vals{"id"}.getStrVal
|
||||||
|
if spaceId.len > 0:
|
||||||
|
result.url = "/i/spaces/" & spaceId
|
||||||
|
result.title = "Twitter Space"
|
||||||
|
result.text = "Click to view Space"
|
||||||
|
of unknown:
|
||||||
result.title = "This card type is not supported."
|
result.title = "This card type is not supported."
|
||||||
else: discard
|
else: discard
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,21 @@ proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} =
|
||||||
await cache(result)
|
await cache(result)
|
||||||
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
|
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
|
||||||
|
|
||||||
|
proc cache*(data: AudioSpace) {.async.} =
|
||||||
|
if data.id.len == 0: return
|
||||||
|
let ttl = if data.state == "RUNNING": baseCacheTime div 6 else: baseCacheTime
|
||||||
|
await setEx("sp:" & data.id, ttl, compress(toFlatty(data)))
|
||||||
|
|
||||||
|
proc getCachedAudioSpace*(id: string): Future[AudioSpace] {.async.} =
|
||||||
|
if id.len == 0: return
|
||||||
|
let cached = await get("sp:" & id)
|
||||||
|
if cached != redisNil:
|
||||||
|
cached.deserialize(AudioSpace)
|
||||||
|
else:
|
||||||
|
result = await getAudioSpace(id)
|
||||||
|
await cache(result)
|
||||||
|
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
|
||||||
|
|
||||||
proc cache*(data: AccountInfo; name: string) {.async.} =
|
proc cache*(data: AccountInfo; name: string) {.async.} =
|
||||||
await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))
|
await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))
|
||||||
|
|
||||||
|
|
|
||||||
36
src/routes/space.nim
Normal file
36
src/routes/space.nim
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import asyncdispatch, strutils
|
||||||
|
import jester
|
||||||
|
|
||||||
|
import router_utils
|
||||||
|
import ".."/[types, formatters, redis_cache]
|
||||||
|
import ../views/[general, space]
|
||||||
|
import media
|
||||||
|
|
||||||
|
export space
|
||||||
|
|
||||||
|
proc createSpaceRouter*(cfg: Config) =
|
||||||
|
router spaceRoute:
|
||||||
|
get "/i/spaces/@id":
|
||||||
|
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
|
||||||
|
let sp = await getCachedAudioSpace(@"id")
|
||||||
|
|
||||||
|
if sp.id.len == 0:
|
||||||
|
resp Http404, showError("Space not found", cfg)
|
||||||
|
|
||||||
|
let prefs = requestPrefs()
|
||||||
|
resp renderMain(renderSpace(sp, prefs, request.path), request, cfg, prefs,
|
||||||
|
sp.title, ogTitle=sp.title)
|
||||||
|
|
||||||
|
get "/i/spaces/@id/stream":
|
||||||
|
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
|
||||||
|
let sp = await getCachedAudioSpace(@"id")
|
||||||
|
|
||||||
|
if sp.m3u8Url.len == 0:
|
||||||
|
resp Http404
|
||||||
|
|
||||||
|
let manifest = await safeFetch(sp.m3u8Url)
|
||||||
|
if manifest.len == 0:
|
||||||
|
resp Http502
|
||||||
|
|
||||||
|
resp proxifyVideo(manifest, requestPrefs().proxyVideos, sp.m3u8Url), m3u8Mime
|
||||||
149
src/sass/_space.scss
Normal file
149
src/sass/_space.scss
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
.space-page {
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 20px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-panel {
|
||||||
|
background-color: var(--bg_panel);
|
||||||
|
border: 1px solid var(--border_grey);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-player {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, #7b2a8c 0%, #9b3ab1 100%);
|
||||||
|
min-height: 140px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
audio {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:not([controls]) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-overlay {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-live {
|
||||||
|
background: #e0245e;
|
||||||
|
color: white;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-info {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--fg_faded);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listener-count {
|
||||||
|
color: var(--fg_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-state {
|
||||||
|
color: var(--fg_dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-participants {
|
||||||
|
border-top: 1px solid var(--border_grey);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-participant {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--fg_color);
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-icon {
|
||||||
|
margin-bottom: 0;
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-username {
|
||||||
|
color: var(--fg_dark);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
@import "timeline";
|
@import "timeline";
|
||||||
@import "search";
|
@import "search";
|
||||||
@import "broadcast";
|
@import "broadcast";
|
||||||
|
@import "space";
|
||||||
@import "_article";
|
@import "_article";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,28 @@ type
|
||||||
availableForReplay*: bool
|
availableForReplay*: bool
|
||||||
user*: User
|
user*: User
|
||||||
|
|
||||||
|
SpaceParticipant* = object
|
||||||
|
userId*: string
|
||||||
|
username*: string
|
||||||
|
displayName*: string
|
||||||
|
avatarUrl*: string
|
||||||
|
isVerified*: bool
|
||||||
|
|
||||||
|
AudioSpace* = object
|
||||||
|
id*: string
|
||||||
|
title*: string
|
||||||
|
state*: string
|
||||||
|
mediaKey*: string
|
||||||
|
m3u8Url*: string
|
||||||
|
totalLiveListeners*: int
|
||||||
|
totalReplayWatched*: int
|
||||||
|
startTime*: DateTime
|
||||||
|
endTime*: DateTime
|
||||||
|
availableForReplay*: bool
|
||||||
|
creator*: User
|
||||||
|
admins*: seq[SpaceParticipant]
|
||||||
|
speakers*: seq[SpaceParticipant]
|
||||||
|
|
||||||
VideoType* = enum
|
VideoType* = enum
|
||||||
m3u8 = "application/x-mpegURL"
|
m3u8 = "application/x-mpegURL"
|
||||||
mp4 = "video/mp4"
|
mp4 = "video/mp4"
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||||
|
|
||||||
buildHtml(head):
|
buildHtml(head):
|
||||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=39")
|
link(rel="stylesheet", type="text/css", href="/css/style.css?v=42")
|
||||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
|
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
|
||||||
|
|
||||||
if theme.len > 0:
|
if theme.len > 0:
|
||||||
|
|
|
||||||
86
src/views/space.nim
Normal file
86
src/views/space.nim
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import strutils, times
|
||||||
|
import karax/[karaxdsl, vdom]
|
||||||
|
|
||||||
|
import renderutils
|
||||||
|
import ".."/[types, utils, formatters]
|
||||||
|
|
||||||
|
proc renderParticipant(p: SpaceParticipant; role: string): VNode =
|
||||||
|
buildHtml(tdiv(class="space-participant")):
|
||||||
|
a(href=("/" & p.username)):
|
||||||
|
genImg(p.avatarUrl.replace("_normal", "_bigger"))
|
||||||
|
tdiv(class="participant-info"):
|
||||||
|
tdiv(class="participant-name"):
|
||||||
|
strong: text p.displayName
|
||||||
|
if p.isVerified:
|
||||||
|
tdiv(class="verified-icon blue"):
|
||||||
|
icon "circle", class="verified-icon-circle", title="Verified account"
|
||||||
|
icon "ok", class="verified-icon-check", title="Verified account"
|
||||||
|
if role.len > 0:
|
||||||
|
span(class="host-badge"): text role
|
||||||
|
span(class="participant-username"): text "@" & p.username
|
||||||
|
|
||||||
|
proc renderSpace*(sp: AudioSpace; prefs: Prefs; path: string): VNode =
|
||||||
|
let
|
||||||
|
isLive = sp.state == "RUNNING"
|
||||||
|
source = if prefs.proxyVideos and sp.m3u8Url.startsWith("http"):
|
||||||
|
getVidUrl(sp.m3u8Url) else: sp.m3u8Url
|
||||||
|
stateText =
|
||||||
|
if isLive: "LIVE"
|
||||||
|
elif sp.endTime.year > 1: "Ended " & sp.endTime.format("MMM d, YYYY")
|
||||||
|
elif sp.state.len > 0: sp.state
|
||||||
|
else: "Ended"
|
||||||
|
durationMs =
|
||||||
|
if sp.startTime.year > 1 and sp.endTime.year > 1:
|
||||||
|
int((sp.endTime - sp.startTime).inMilliseconds)
|
||||||
|
else: 0
|
||||||
|
duration = if durationMs > 0: getDuration(durationMs) else: ""
|
||||||
|
totalListeners =
|
||||||
|
if sp.totalReplayWatched > 0: sp.totalReplayWatched
|
||||||
|
else: sp.totalLiveListeners
|
||||||
|
|
||||||
|
buildHtml(tdiv(class="space-page")):
|
||||||
|
tdiv(class="space-panel"):
|
||||||
|
tdiv(class="space-player"):
|
||||||
|
if sp.m3u8Url.len > 0 and prefs.hlsPlayback:
|
||||||
|
audio(data-url=source, data-autoload="false")
|
||||||
|
verbatim "<div class=\"video-overlay\" onclick=\"playAudio(this)\">"
|
||||||
|
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||||||
|
if isLive:
|
||||||
|
tdiv(class="space-live"): text "LIVE"
|
||||||
|
elif duration.len > 0:
|
||||||
|
tdiv(class="overlay-duration"): text duration
|
||||||
|
verbatim "</div>"
|
||||||
|
elif sp.m3u8Url.len > 0:
|
||||||
|
tdiv(class="video-overlay"):
|
||||||
|
buttonReferer "/enablehls", "Enable hls playback", path
|
||||||
|
if isLive:
|
||||||
|
tdiv(class="space-live"): text "LIVE"
|
||||||
|
elif duration.len > 0:
|
||||||
|
tdiv(class="overlay-duration"): text duration
|
||||||
|
elif sp.availableForReplay:
|
||||||
|
tdiv(class="video-overlay"):
|
||||||
|
p: text "Audio stream unavailable"
|
||||||
|
else:
|
||||||
|
tdiv(class="video-overlay"):
|
||||||
|
p: text "Replay is not available"
|
||||||
|
|
||||||
|
tdiv(class="space-info"):
|
||||||
|
tdiv(class="space-header"):
|
||||||
|
h2(class="space-title"): text sp.title
|
||||||
|
tdiv(class="space-meta"):
|
||||||
|
if totalListeners > 0:
|
||||||
|
span(class="listener-count"): text insertSep($totalListeners, ',') & " listeners"
|
||||||
|
if isLive:
|
||||||
|
span(class="space-live"): text stateText
|
||||||
|
else:
|
||||||
|
span(class="space-state"): text stateText
|
||||||
|
|
||||||
|
if sp.admins.len > 0 or sp.speakers.len > 0:
|
||||||
|
tdiv(class="space-participants"):
|
||||||
|
for admin in sp.admins:
|
||||||
|
let role = if admin.username == sp.creator.username: "Host"
|
||||||
|
else: "Co-host"
|
||||||
|
renderParticipant(admin, role)
|
||||||
|
for speaker in sp.speakers:
|
||||||
|
renderParticipant(speaker, "")
|
||||||
Loading…
Reference in a new issue