commit 0b37f5b3f349601aadfbbcefb9ed462a6654f5f3
parent 954870c445649fc17d5ebfa8cab37916b9422c6a
Author: William Casarin <jb55@jb55.com>
Date: Mon, 14 Nov 2022 11:43:48 -0800
Explore View
Diffstat:
M | web/index.html | | | 43 | ++++++++++++++++++++++++++++++++++++------- |
M | web/js/damus.js | | | 252 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------- |
M | web/js/ui/render.js | | | 107 | +++++++++++++++++++++++++++++++++++-------------------------------------------- |
3 files changed, 266 insertions(+), 136 deletions(-)
diff --git a/web/index.html b/web/index.html
@@ -4,15 +4,15 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Damus</title>
- <link rel="stylesheet" href="css/styles.css?v=116">
- <link rel="stylesheet" href="css/responsive.css?v=7">
+ <link rel="stylesheet" href="css/styles.css?v=117">
+ <link rel="stylesheet" href="css/responsive.css?v=8">
<link rel="stylesheet" href="css/fontawesome.css?v=2">
- <script defer src="js/ui/util.js?v=1"></script>
- <script defer src="js/ui/render.js?v=1"></script>
+ <script defer src="js/ui/util.js?v=5"></script>
+ <script defer src="js/ui/render.js?v=5"></script>
<script defer src="js/noble-secp256k1.js?v=1"></script>
<script defer src="js/bech32.js?v=1"></script>
<script defer src="js/nostr.js?v=6"></script>
- <script defer src="js/damus.js?v=73"></script>
+ <script defer src="js/damus.js?v=74"></script>
</head>
<body>
<script>
@@ -40,15 +40,44 @@
<!--<img src="https://damus.io/img/damus.svg">-->
<i class="fa-regular fa-fw fa-hand-peace"></i>
</div>
- <button class="nav icon" title="Home">
+ <button id="home-button" onclick="switch_view('home')" class="nav icon" title="Home">
<i class="fa fa-fw fa-home"></i><span class="hide">Home</span>
</button>
+ <button id="explore-button" onclick="switch_view('explore')" class="nav icon" title="Explore">
+ <i class="fa fa-fw fa-globe"></i><span class="hide">Explore</span>
+ </button>
<button onclick="press_logout()" title="Sign Out" class="nav icon">
<i class="fa fa-fw fa-arrow-right-from-bracket"></i><span class="hide">Sign Out</span>
</button>
</div>
</div>
- <div id="view"></div>
+ <div id="view">
+ <div id="home-view">
+ <header>
+ <label>Home</label>
+ </header>
+ <div id="newpost">
+ <div class="my-userpic vertical-hide"><!-- To be loaded. --></div>
+ <div>
+ <textarea placeholder="What's up?" oninput="post_input_changed(this)" class="post-input" id="post-input"></textarea>
+ <div class="post-tools">
+ <input id="content-warning-input" class="cw hide" type="text" placeholder="Reason"/>
+ <button title="Mark this message as sensitive." onclick="toggle_cw(this)" class="cw icon">
+ <i class="fa-solid fa-triangle-exclamation"></i>
+ </button>
+ <button onclick="send_post(this)" class="action" id="post-button" disabled>Send</button>
+ </div>
+ </div>
+ </div>
+ <div class="events"></div>
+ </div>
+ <div style="display:none" id="explore-view">
+ <header>
+ <label>Explore</label>
+ </header>
+ <div class="events"></div>
+ </div>
+ </div>
<div class="flex-fill vertical-hide"></div>
</div>
<div class="modal closed" id="reply-modal">
diff --git a/web/js/damus.js b/web/js/damus.js
@@ -39,19 +39,31 @@ function init_contacts() {
}
}
+function init_timeline(name) {
+ return {
+ name,
+ events: [],
+ rendered: new Set(),
+ expanded: new Set(),
+ }
+}
+
function init_home_model() {
return {
done_init: false,
- loading: true,
notifications: 0,
- rendered: {},
all_events: {},
- expanded: new Set(),
reactions_to: {},
- events: [],
chatrooms: {},
deletions: {},
cw_open: {},
+ views: {
+ home: init_timeline('home'),
+ explore: {
+ ...init_timeline('explore'),
+ seen: new Set(),
+ }
+ },
deleted: {},
profiles: {},
profile_events: {},
@@ -106,6 +118,7 @@ async function damus_web_init()
const ids = {
comments: "comments",//uuidv4(),
profiles: "profiles",//uuidv4(),
+ explore: "explore",//uuidv4(),
refevents: "refevents",//uuidv4(),
account: "account",//uuidv4(),
home: "home",//uuidv4(),
@@ -116,7 +129,8 @@ async function damus_web_init()
model.pool = pool
model.view_el = document.querySelector("#view")
- redraw_home_view(model)
+
+ switch_view('home')
document.addEventListener('visibilitychange', () => {
update_title(model)
@@ -128,7 +142,6 @@ async function damus_web_init()
log_debug("relay connected", relay.url)
if (!model.done_init) {
- model.loading = false
send_initial_filters(ids.account, model.pubkey, relay)
} else {
send_home_filters(ids, model, relay)
@@ -142,9 +155,11 @@ async function damus_web_init()
pool.on('eose', async (relay, sub_id) => {
if (sub_id === ids.home) {
- handle_comments_loaded(ids.profiles, model, relay)
+ const events = model.views.home.events
+ handle_comments_loaded(ids, model, events, relay)
} else if (sub_id === ids.profiles) {
- handle_profiles_loaded(ids.profiles, model, relay)
+ const view = get_current_view()
+ handle_profiles_loaded(ids, model, view, relay)
}
})
@@ -262,6 +277,8 @@ function process_event(model, ev)
process_deletion_event(model, ev)
else if (ev.kind === 0)
process_profile_event(model, ev)
+ else if (ev.kind === 3)
+ process_contact_event(model, ev)
const last_notified = get_local_state('last_notified_date')
if (notified && (last_notified == null || ((ev.created_at*1000) > last_notified))) {
@@ -287,36 +304,70 @@ function was_pubkey_notified(pubkey, ev)
return false
}
-function should_add_to_home(ev)
+function should_add_to_timeline(ev)
{
return ev.kind === 1 || ev.kind === 42 || ev.kind === 6
}
-let rerender_home_timer
+function should_add_to_explore_timeline(view, ev)
+{
+ if (!should_add_to_timeline(ev))
+ return false
+
+ if (view.seen.has(ev.pubkey))
+ return false
+
+ return true
+}
+
+function get_current_view()
+{
+ return DAMUS.views[DAMUS.current_view]
+}
+
+function handle_redraw_logic(model, view_name)
+{
+ if (get_current_view().name === view_name) {
+ const view = model.views[view_name]
+ if (view.redraw_timer)
+ clearTimeout(view.redraw_timer)
+ view.redraw_timer = setTimeout(redraw_events.bind(null, model, view), 500)
+ }
+}
+
function handle_home_event(ids, model, relay, sub_id, ev) {
// ignore duplicates
- if (model.all_events[ev.id])
- return
+ if (!model.all_events[ev.id]) {
+ model.all_events[ev.id] = ev
+ process_event(model, ev)
+ }
- model.all_events[ev.id] = ev
- process_event(model, ev)
+ ev = model.all_events[ev.id]
+ let is_new = false
switch (sub_id) {
- case ids.home:
- if (should_add_to_home(ev))
- insert_event_sorted(model.events, ev)
+ case ids.explore:
+ const view = model.views.explore
- if (model.realtime) {
- if (rerender_home_timer)
- clearTimeout(rerender_home_timer)
- rerender_home_timer = setTimeout(redraw_events.bind(null, model), 500)
+ if (should_add_to_explore_timeline(view, ev)) {
+ view.seen.add(ev.pubkey)
+ is_new = insert_event_sorted(view.events, ev)
}
+
+ if (is_new)
+ handle_redraw_logic(model, 'explore')
+ break;
+
+ case ids.home:
+ if (should_add_to_timeline(ev))
+ is_new = insert_event_sorted(model.views.home.events, ev)
+
+ if (is_new)
+ handle_redraw_logic(model, 'home')
break;
case ids.account:
switch (ev.kind) {
case 3:
- model.loading = false
- process_contact_event(model, ev)
model.done_init = true
model.pool.unsubscribe(ids.account, [relay])
break
@@ -349,11 +400,15 @@ function send_home_filters(ids, model, relay) {
const friends = contacts_friend_list(model.contacts)
friends.push(model.pubkey)
+
const contacts_filter = {kinds: [0], authors: friends}
const dms_filter = {kinds: [4], limit: 500}
const our_dms_filter = {kinds: [4], authors: [ model.pubkey ], limit: 500}
- const home_filter = {kinds: [1,42,5,6,7], authors: friends, limit: 500}
- const notifications_filter = {kinds: [1,42,6,7], "#p": [model.pubkey], limit: 100}
+
+ const standard_kinds = [1,42,5,6,7]
+
+ const home_filter = {kinds: standard_kinds, authors: friends, limit: 500}
+ const notifications_filter = {kinds: standard_kinds, "#p": [model.pubkey], limit: 100}
let home_filters = [home_filter]
let notifications_filters = [notifications_filter]
@@ -422,6 +477,23 @@ function contacts_friend_list(contacts) {
return Array.from(contacts.friends)
}
+function contacts_friendosphere(contacts) {
+ let s = new Set()
+ let fs = []
+
+ for (const friend of contacts.friends.keys()) {
+ fs.push(friend)
+ s.add(friend)
+ }
+
+ for (const friend of contacts.friend_of_friends.keys()) {
+ if (!s.has(friend))
+ fs.push(friend)
+ }
+
+ return fs
+}
+
function process_contact_event(model, ev) {
load_our_contacts(model.contacts, model.pubkey, ev)
load_our_relays(model.pubkey, model.pool, ev)
@@ -429,9 +501,8 @@ function process_contact_event(model, ev) {
}
function add_contact_if_friend(contacts, ev) {
- if (!contact_is_friend(contacts, ev.pubkey)) {
+ if (!contact_is_friend(contacts, ev.pubkey))
return
- }
add_friend_contact(contacts, ev)
}
@@ -441,13 +512,50 @@ function contact_is_friend(contacts, pk) {
}
function add_friend_contact(contacts, contact) {
- contacts.friends[contact.pubkey] = true
+ contacts.friends.add(contact.pubkey)
- for (const tag of contact.tags) {
- if (tag.count >= 2 && tag[0] == "p") {
- contacts.friend_of_friends.add(tag[1])
- }
- }
+ for (const tag of contact.tags) {
+ if (tag.length >= 2 && tag[0] == "p") {
+ if (!contact_is_friend(contacts, tag[1]))
+ contacts.friend_of_friends.add(tag[1])
+ }
+ }
+}
+
+function get_view_el(name)
+{
+ return DAMUS.view_el.querySelector(`#${name}-view`)
+}
+
+function switch_view(name, opts={})
+{
+ if (name === DAMUS.current_view) {
+ log_debug("Not switching to '%s', we are already there", name)
+ return
+ }
+
+ const last = get_current_view()
+ if (!last) {
+ // render initial
+ DAMUS.current_view = name
+ redraw_timeline_events(DAMUS, name)
+ return
+ }
+
+ log_debug("switching to '%s' by hiding '%s'", name, DAMUS.current_view)
+
+ DAMUS.current_view = name
+ const current = get_current_view()
+ const last_el = get_view_el(last.name)
+ const current_el = get_view_el(current.name)
+
+ if (last_el)
+ last_el.style.display = "none";
+
+ redraw_timeline_events(DAMUS, name)
+
+ if (current_el)
+ current_el.style.display = "block";
}
function load_our_relays(our_pubkey, pool, ev) {
@@ -486,10 +594,10 @@ function load_our_contacts(contacts, our_pubkey, ev) {
}
}
-function get_referenced_events(model)
+function get_referenced_events(model, events)
{
let evset = new Set()
- for (const ev of model.events) {
+ for (const ev of events) {
for (const tag of ev.tags) {
if (tag.length >= 2 && tag[0] === "e") {
const e = tag[1]
@@ -509,12 +617,18 @@ function fetch_referenced_events(refevents_id, model, relay) {
model.pool.subscribe(refevents_id, [filter], relay)
}
-function handle_profiles_loaded(profiles_id, model, relay) {
+function handle_profiles_loaded(ids, model, view, relay) {
// stop asking for profiles
- model.pool.unsubscribe(profiles_id, relay)
- model.realtime = true
- redraw_events(model)
+ model.pool.unsubscribe(ids.profiles, relay)
+ redraw_events(model, view)
redraw_my_pfp(model)
+
+ const fofs = Array.from(model.contacts.friend_of_friends)
+ let explore_filters = [
+ {kinds: [1,42], authors: fofs, limit: 200},
+ {kinds: [1,42], ids: ["0000"], limit: 200}
+ ]
+ model.pool.subscribe(ids.explore, explore_filters, relay)
}
function redraw_my_pfp(model) {
@@ -547,9 +661,9 @@ function get_unknown_chatroom_ids(state)
}
// load profiles after comment notes are loaded
-function handle_comments_loaded(profiles_id, model, relay)
+function handle_comments_loaded(ids, model, events, relay)
{
- const pubkeys = model.events.reduce((s, ev) => {
+ const pubkeys = events.reduce((s, ev) => {
s.add(ev.pubkey)
for (const tag of ev.tags) {
if (tag.length >= 2 && tag[0] === "p") {
@@ -563,49 +677,46 @@ function handle_comments_loaded(profiles_id, model, relay)
// load profiles and noticed chatrooms
const chatroom_ids = get_unknown_chatroom_ids(model)
- const profile_filter = {kinds: [0], authors: authors}
+ const profile_filter = {kinds: [0,3], authors: authors}
const chatroom_filter = {kinds: [40], ids: chatroom_ids}
let filters = [profile_filter, chatroom_filter]
- const ref_evids = get_referenced_events(model)
+ const ref_evids = get_referenced_events(model, events)
if (ref_evids.length > 0) {
- log_debug("got %d new referenced events to pull after initial load", ref_evids.length)
+ log_debug("got %d new referenced events to pull from %s after initial load", ref_evids.length, relay.url)
filters.push({ids: ref_evids})
filters.push({"#e": ref_evids})
}
//console.log("subscribe", profiles_id, filter, relay)
- model.pool.subscribe(profiles_id, filters, relay)
+ model.pool.subscribe(ids.profiles, filters, relay)
}
-function redraw_events(model) {
- //log_debug("rendering home view")
- model.rendered = {}
- model.events_el.innerHTML = render_events(model)
- setup_home_event_handlers(model.events_el)
+function redraw_events(damus, view) {
+ //log_debug("redrawing events for", view)
+ view.rendered = new Set()
+
+ const events_el = damus.view_el.querySelector(`#${view.name}-view > .events`)
+ events_el.innerHTML = render_events(damus, view)
+
+ setup_timeline_event_handlers(events_el)
}
-function setup_home_event_handlers(events_el)
+function setup_timeline_event_handlers(events_el)
{
for (const el of events_el.querySelectorAll(".cw"))
el.addEventListener("toggle", toggle_content_warning.bind(null))
}
-function redraw_home_view(model) {
- model.view_el.innerHTML = render_home_view(model)
- model.events_el = document.querySelector("#events")
- if (model.events.length > 0) {
- redraw_events(model)
+function redraw_timeline_events(damus, name) {
+ const view = DAMUS.views[name]
+ const events_el = damus.view_el.querySelector(`#${name}-view > .events`)
+
+ if (view.events.length > 0) {
+ redraw_events(damus, view)
} else {
- model.events_el.innerHTML= `
- <div class="loading-events">
- <span class="loader" title="Loading...">
- <i class="fa-solid fa-fw fa-spin fa-hurricane"
- style="--fa-animation-duration: 0.5s;"></i>
- </span>
- </div>
- `
+ events_el.innerHTML = render_loading_spinner()
}
}
@@ -710,14 +821,15 @@ function* yield_etags(tags)
function expand_thread(id) {
const ev = DAMUS.all_events[id]
+ const view = get_current_view()
if (ev) {
for (const tag of yield_etags(ev.tags))
- DAMUS.expanded.add(tag[1])
+ view.expanded.add(tag[1])
} else {
log_debug("expand_thread, id not found?", id)
}
- DAMUS.expanded.add(id)
- redraw_events(DAMUS)
+ view.expanded.add(id)
+ redraw_events(DAMUS, view)
}
function delete_post_confirm(evid) {
@@ -732,11 +844,11 @@ function delete_post_confirm(evid) {
delete_post(evid, reason)
}
-function shouldnt_render_event(model, ev, opts) {
+function shouldnt_render_event(view, ev, opts) {
return !opts.is_boost_event &&
!opts.is_composing &&
- !model.expanded.has(ev.id) &&
- model.rendered[ev.id]
+ !view.expanded.has(ev.id) &&
+ view.rendered.has(ev.id)
}
function press_logout() {
diff --git a/web/js/ui/render.js b/web/js/ui/render.js
@@ -2,42 +2,19 @@
// is done by simple string manipulations & templates. If you need to write
// loops simply write it in code and return strings.
-function render_home_view(model) {
- return `
- <header>
- <label>Home</label>
- </header>
- <div id="newpost">
- <div class="my-userpic vertical-hide"><!-- To be loaded. --></div>
- <div>
- <textarea placeholder="What's up?" oninput="post_input_changed(this)" class="post-input" id="post-input"></textarea>
- <div class="post-tools">
- <input id="content-warning-input" class="cw hide" type="text" placeholder="Reason"/>
- <button title="Mark this message as sensitive." onclick="toggle_cw(this)" class="cw icon">
- <i class="fa-solid fa-triangle-exclamation"></i>
- </button>
- <button onclick="send_post(this)" class="action" id="post-button" disabled>Send</button>
- </div>
- </div>
- </div>
- <div id="events"></div>
- `
-}
-
-function render_home_event(model, ev)
+function render_timeline_event(damus, view, ev)
{
let max_depth = 3
- if (ev.refs && ev.refs.root && model.expanded.has(ev.refs.root)) {
+ if (ev.refs && ev.refs.root && view.expanded.has(ev.refs.root))
max_depth = null
- }
- return render_event(model, ev, {max_depth})
+ return render_event(damus, view, ev, {max_depth})
}
-function render_events(model) {
- return model.events
+function render_events(damus, view) {
+ return view.events
.filter((ev, i) => i < 140)
- .map((ev) => render_home_event(model, ev)).join("\n")
+ .map((ev) => render_timeline_event(damus, view, ev)).join("\n")
}
function render_reply_line_top(has_top_line) {
@@ -60,29 +37,29 @@ function render_thread_collapsed(model, reply_ev, opts)
</div>`
}
-function render_replied_events(model, ev, opts)
+function render_replied_events(damus, view, ev, opts)
{
if (!(ev.refs && ev.refs.reply))
return ""
- const reply_ev = model.all_events[ev.refs.reply]
+ const reply_ev = damus.all_events[ev.refs.reply]
if (!reply_ev)
return ""
opts.replies = opts.replies == null ? 1 : opts.replies + 1
- const expanded = model.expanded.has(reply_ev.id)
+ const expanded = view.expanded.has(reply_ev.id)
if (!expanded && !(opts.max_depth == null || opts.replies < opts.max_depth))
- return render_thread_collapsed(model, reply_ev, opts)
+ return render_thread_collapsed(damus, reply_ev, opts)
opts.is_reply = true
- return render_event(model, reply_ev, opts)
+ return render_event(damus, view, reply_ev, opts)
}
-function render_replying_to_chat(model, ev) {
- const chatroom = (ev.refs.root && model.chatrooms[ev.refs.root]) || {}
+function render_replying_to_chat(damus, ev) {
+ const chatroom = (ev.refs.root && damus.chatrooms[ev.refs.root]) || {}
const roomname = chatroom.name || ev.refs.root || "??"
const pks = ev.refs.pubkeys || []
- const names = pks.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ")
+ const names = pks.map(pk => render_mentioned_name(pk, damus.profiles[pk])).join(", ")
const to_users = pks.length === 0 ? "" : ` to ${names}`
return `<div class="replying-to">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>`
@@ -117,7 +94,7 @@ function render_unknown_event(model, ev) {
return "Unknown event"
}
-function render_boost(model, ev, opts) {
+function render_boost(damus, view, ev, opts) {
//todo validate content
if (!ev.json_content)
return render_unknown_event(ev)
@@ -125,30 +102,30 @@ function render_boost(model, ev, opts) {
//const profile = model.profiles[ev.pubkey]
opts.boosted = {
pubkey: ev.pubkey,
- profile: model.profiles[ev.pubkey]
+ profile: damus.profiles[ev.pubkey]
}
- return render_event(model, ev.json_content, opts)
+ return render_event(damus, view, ev.json_content, opts)
}
-function render_comment_body(model, ev, opts) {
- const can_delete = model.pubkey === ev.pubkey;
- const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(ev, can_delete)
+function render_comment_body(damus, ev, opts) {
+ const can_delete = damus.pubkey === ev.pubkey;
+ const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(damus, ev, can_delete)
const show_media = !opts.is_composing
return `
<div>
- ${render_replying_to(model, ev)}
- ${render_boosted_by(model, ev, opts)}
+ ${render_replying_to(damus, ev)}
+ ${render_boosted_by(ev, opts)}
</div>
<p>
${format_content(ev, show_media)}
</p>
- ${render_reactions(model, ev)}
+ ${render_reactions(damus, ev)}
${bar}
`
}
-function render_boosted_by(model, ev, opts) {
+function render_boosted_by(ev, opts) {
const b = opts.boosted
if (!b) {
return ""
@@ -177,23 +154,25 @@ function render_deleted_comment_body(ev, deleted) {
`
}
-function render_event(model, ev, opts={}) {
+function render_event(damus, view, ev, opts={}) {
if (ev.kind === 6)
- return render_boost(model, ev, opts)
- if (shouldnt_render_event(model, ev, opts))
+ return render_boost(damus, view, ev, opts)
+ if (shouldnt_render_event(view, ev, opts))
return ""
- model.rendered[ev.id] = true
- const profile = model.profiles[ev.pubkey] || DEFAULT_PROFILE
+
+ view.rendered.add(ev.id)
+
+ const profile = damus.profiles[ev.pubkey] || DEFAULT_PROFILE
const delta = time_delta(new Date().getTime(), ev.created_at*1000)
const has_bot_line = opts.is_reply
const reply_line_bot = (has_bot_line && render_reply_line_bot()) || ""
- const deleted = is_deleted(model, ev.id)
+ const deleted = is_deleted(damus, ev.id)
if (deleted && !opts.is_reply)
return ""
- const replied_events = render_replied_events(model, ev, opts)
+ const replied_events = render_replied_events(damus, view, ev, opts)
let name = ""
if (!deleted) {
@@ -218,7 +197,7 @@ function render_event(model, ev, opts={}) {
<span class="timestamp">${delta}</span>
</div>
<div class="comment">
- ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(model, ev, opts)}
+ ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(damus, ev, opts)}
</div>
</div>
</div>
@@ -258,15 +237,15 @@ function render_reaction(model, reaction) {
return render_pfp(reaction.pubkey, profile)
}
-function render_action_bar(ev, can_delete) {
+function render_action_bar(damus, ev, can_delete) {
let delete_html = ""
if (can_delete)
delete_html = `<button class="icon" title="Delete" onclick="delete_post_confirm('${ev.id}')"><i class="fa fa-fw fa-trash"></i></a>`
- const groups = get_reactions(DAMUS, ev.id)
+ const groups = get_reactions(damus, ev.id)
const like = "❤️"
const likes = groups[like] || {}
- const react_onclick = render_react_onclick(DAMUS.pubkey, ev.id, like, likes)
+ const react_onclick = render_react_onclick(damus.pubkey, ev.id, like, likes)
return `
<div class="action-bar">
<button class="icon" title="Reply" onclick="reply_to('${ev.id}')"><i class="fa fa-fw fa-comment"></i></a>
@@ -342,4 +321,14 @@ function render_deleted_pfp() {
</div>`
}
-
+function render_loading_spinner()
+{
+ return `
+ <div class="loading-events">
+ <span class="loader" title="Loading...">
+ <i class="fa-solid fa-fw fa-spin fa-hurricane"
+ style="--fa-animation-duration: 0.5s;"></i>
+ </span>
+ </div>
+ `
+}