diff --git a/apps/tlon-mobile/android/app/build.gradle b/apps/tlon-mobile/android/app/build.gradle index 0268fbb891..af13d348a5 100644 --- a/apps/tlon-mobile/android/app/build.gradle +++ b/apps/tlon-mobile/android/app/build.gradle @@ -88,7 +88,7 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion compileSdk rootProject.ext.compileSdkVersion versionCode 108 - versionName "4.2.3" + versionName "4.2.4" buildConfigField("boolean", "REACT_NATIVE_UNSTABLE_USE_RUNTIME_SCHEDULER_ALWAYS", (findProperty("reactNative.unstable_useRuntimeSchedulerAlways") ?: true).toString()) } diff --git a/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj b/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj index ec04793a11..5d30e4e75d 100644 --- a/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj +++ b/apps/tlon-mobile/ios/Landscape.xcodeproj/project.pbxproj @@ -1427,7 +1427,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.2.3; + MARKETING_VERSION = 4.2.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1465,7 +1465,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.2.3; + MARKETING_VERSION = 4.2.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1689,7 +1689,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.2.3; + MARKETING_VERSION = 4.2.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1732,7 +1732,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.2.3; + MARKETING_VERSION = 4.2.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/tlon-web-new/src/logic/useAppUpdates.ts b/apps/tlon-web-new/src/logic/useAppUpdates.ts index 5487edea9c..8324e81c3d 100644 --- a/apps/tlon-web-new/src/logic/useAppUpdates.ts +++ b/apps/tlon-web-new/src/logic/useAppUpdates.ts @@ -1,3 +1,4 @@ +import { queryClient } from '@tloncorp/shared'; import { createContext, useCallback, useEffect, useState } from 'react'; import { useRegisterSW } from 'virtual:pwa-register/react'; @@ -78,6 +79,7 @@ export default function useAppUpdates() { : `${window.location.href}?updatedAt=${Date.now()}`; if (needRefresh) { + queryClient.clear(); try { await updateServiceWorker(false); } catch (e) { diff --git a/apps/tlon-web-new/src/main.tsx b/apps/tlon-web-new/src/main.tsx index 7d94a02cb2..278636433d 100644 --- a/apps/tlon-web-new/src/main.tsx +++ b/apps/tlon-web-new/src/main.tsx @@ -57,8 +57,8 @@ setupDb().then(() => { diff --git a/desk/app/activity.hoon b/desk/app/activity.hoon index a89fc84acf..47418eeebf 100644 --- a/desk/app/activity.hoon +++ b/desk/app/activity.hoon @@ -590,7 +590,7 @@ :: we only care about posts/replies events that are notified, and we :: don't want to include events from sources whose latest event is :: after the start so we always get "new" sources when paging - ?. ?& notified.event + ?. ?& ?|(notified.event ?=(%contact -<.event)) (lth latest.src-info start) ?= $? %post %reply %dm-post %dm-reply %flag-post %flag-reply %group-ask @@ -613,6 +613,14 @@ ?^ mention :- sources.acc [(sub limit.acc 1) (snoc happenings.acc u.mention) collapsed.acc] + =/ contact-bundle=(unit activity-bundle:a) + ?. ?=(%all type) ~ + =/ is-contact-event ?=(%contact -<.event) + ?. is-contact-event ~ + `[source time ~[[time event]]] + ?^ contact-bundle + :- sources.acc + [(sub limit.acc 1) (snoc happenings.acc u.contact-bundle) collapsed.acc] =/ care ?| ?=(%all type) &(?=(%replies type) ?=(?(%reply %dm-reply) -<.event)) diff --git a/desk/app/channels-server.hoon b/desk/app/channels-server.hoon index 79f241dcb2..635ab2378e 100644 --- a/desk/app/channels-server.hoon +++ b/desk/app/channels-server.hoon @@ -2,9 +2,10 @@ :: :: this is the server-side from which /app/channels gets its data. :: -/- c=channels, g=groups +/- c=channels, g=groups, h=hooks, m=meta /+ utils=channel-utils, imp=import-aid /+ default-agent, verb, dbug, neg=negotiate, logs +/+ hj=hooks-json :: %- %- agent:neg [| [~.channels^%1 ~ ~] ~] @@ -16,8 +17,9 @@ |% +$ card card:agent:gall +$ current-state - $: %6 + $: %7 =v-channels:c + =hooks:h =pimp:imp == -- @@ -56,7 +58,7 @@ abet:(watch:cor path) [cards this] :: - ++ on-peek on-peek:def + ++ on-peek peek:cor ++ on-leave on-leave:def ++ on-fail |= [=term =tang] @@ -71,7 +73,12 @@ abet:(agent:cor wire sign) [cards this] :: - ++ on-arvo on-arvo:def + ++ on-arvo + |= [=wire sign=sign-arvo] + ^- (quip card _this) + =^ cards state + abet:(arvo:cor wire sign) + [cards this] -- :: |_ [=bowl:gall cards=(list card)] @@ -97,12 +104,22 @@ =? old ?=(%3 -.old) (state-3-to-4 old) =? old ?=(%4 -.old) (state-4-to-5 old) =? old ?=(%5 -.old) (state-5-to-6 old) - ?> ?=(%6 -.old) + =? old ?=(%6 -.old) (state-6-to-7 old) + ?> ?=(%7 -.old) =. state old inflate-io :: - +$ versioned-state $%(state-6 state-5 state-4 state-3 state-2 state-1 state-0) - +$ state-6 current-state + +$ versioned-state $%(state-7 state-6 state-5 state-4 state-3 state-2 state-1 state-0) + +$ state-7 current-state + +$ state-6 + $: %6 + =v-channels:c + =pimp:imp + == + ++ state-6-to-7 + |= state-6 + ^- state-7 + [%7 v-channels *hooks:h pimp] +$ state-5 $: %5 =v-channels:v6:old:c @@ -326,6 +343,38 @@ [~ %| *] ~& [dap.bowl %overwriting-pending-import] cor(pimp `|+egg-any) == + :: + %hook-action-0 + =+ !<(=action:h vase) + ?> =(our src):bowl + ?- -.action + %add + ho-abet:(ho-add:ho-core [name src]:action) + :: + %edit + ho-abet:(ho-edit:(ho-abed:ho-core id.action) +>.action) + :: + %del + ho-abet:ho-del:(ho-abed:ho-core id.action) + :: + %order + =/ seq + %+ skim + seq.action + |= =id:h + (~(has by hooks.hooks) id) + =. order.hooks (~(put by order.hooks) nest.action seq) + (give-hook-response %order nest.action seq) + :: + %config + ho-abet:(ho-configure:(ho-abed:ho-core id.action) +>.action) + :: + %wait + ho-abet:(ho-wait:(ho-abed:ho-core id.action) +>.action) + :: + %rest + ho-abet:(ho-rest:(ho-abed:ho-core id.action) origin.action) + == == :: ++ run-import @@ -371,6 +420,12 @@ ^+ cor ~| watch-path=`path`pole ?+ pole ~|(%bad-watch-path !!) + [%v0 %hooks ~] cor + :: + [%v0 %hooks %full ~] + =. cor (give %fact ~ hook-full+!>(hooks)) + (give %kick ~ ~) + :: [=kind:c name=@ %create ~] ?> =(our src):bowl =* nest [kind.pole our.bowl name.pole] @@ -428,6 +483,14 @@ %- (slog 'diary-server: poke failure' >wire< u.p.sign) cor == + :: + [%hooks %effect ~] + ?+ -.sign !! + %poke-ack + ?~ p.sign cor + %- (slog 'hook effect: poke failure' >wire< u.p.sign) + cor + == :: [%groups ~] ?+ -.sign !! @@ -465,6 +528,24 @@ == == :: +++ peek + |= =(pole knot) + ^- (unit (unit cage)) + =? +.pole !?=([%v0 *] +.pole) + [%v0 +.pole] + ?+ pole [~ ~] + [%x %v0 %hooks ~] + ``hook-full+!>(hooks) + == +:: +++ arvo + |= [=(pole knot) sign=sign-arvo] + ^+ cor + ?+ pole ~|(bad-arvo-take/pole !!) + [%hooks rest=*] + (wakeup-hook rest.pole) + == +:: ++ watch-groups (safe-watch /groups [our.bowl %groups] /groups) ++ take-groups |= =action:g @@ -642,7 +723,7 @@ (ca-update %perm perm.channel) :: %post - =^ update=(unit u-channel:c) posts.channel + =^ update=(unit u-channel:c) ca-core (ca-c-post c-post.c-channel) ?~ update ca-core (ca-update u.update) @@ -650,11 +731,12 @@ :: ++ ca-c-post |= =c-post:c - ^- [(unit u-channel:c) _posts.channel] + ^- [(unit u-channel:c) _ca-core] ?> (can-write:ca-perms src.bowl writers.perm.perm.channel) + =* no-op `ca-core ?- -.c-post %add - ?> =(src.bowl author.essay.c-post) + ?> |(=(src.bowl our.bowl) =(src.bowl author.essay.c-post)) ?> =(kind.nest -.kind-data.essay.c-post) =/ id=id-post:c |- @@ -662,52 +744,93 @@ ?~ post now.bowl $(now.bowl `@da`(add now.bowl ^~((div ~s1 (bex 16))))) =/ new=v-post:c [[id ~ ~] 0 essay.c-post] + =^ result=(each event:h tang) cor + =/ =event:h [%on-post %add new] + (run-hooks event nest 'post blocked') + ?: ?=(%.n -.result) + ((slog p.result) [~ ca-core]) + =. new + ?> ?=([%on-post %add *] p.result) + post.p.result :- `[%post id %set ~ new] - (put:on-v-posts:c posts.channel id ~ new) + ca-core(posts.channel (put:on-v-posts:c posts.channel id ~ new)) :: %edit ?> |(=(src.bowl author.essay.c-post) (is-admin:ca-perms src.bowl)) - ?> =(kind.nest -.kind-data.essay.c-post) =/ post (get:on-v-posts:c posts.channel id.c-post) - ?~ post `posts.channel - ?~ u.post `posts.channel + ?~ post no-op + ?~ u.post no-op ?> |(=(src.bowl author.u.u.post) (is-admin:ca-perms src.bowl)) + =^ result=(each event:h tang) cor + =/ =event:h [%on-post %edit u.u.post essay.c-post] + (run-hooks event nest 'edit blocked') + ?: ?=(%.n -.result) + ((slog p.result) no-op) + =/ =essay:c + ?> ?=([%on-post %edit *] p.result) + essay.p.result ::TODO could optimize and no-op if the edit is identical to current - =/ new=v-post:c [-.u.u.post +(rev.u.u.post) essay.c-post] + =/ new=v-post:c [-.u.u.post +(rev.u.u.post) essay] :- `[%post id.c-post %set ~ new] - (put:on-v-posts:c posts.channel id.c-post ~ new) + ca-core(posts.channel (put:on-v-posts:c posts.channel id.c-post ~ new)) :: %del =/ post (get:on-v-posts:c posts.channel id.c-post) - ?~ post `(put:on-v-posts:c posts.channel id.c-post ~) - ?~ u.post `posts.channel + ?~ post `ca-core(posts.channel (put:on-v-posts:c posts.channel id.c-post ~)) + ?~ u.post no-op ?> |(=(src.bowl author.u.u.post) (is-admin:ca-perms src.bowl)) + =^ result=(each event:h tang) cor + =/ =event:h [%on-post %del u.u.post] + (run-hooks event nest 'delete blocked') + ?> =(& -.result) :- `[%post id.c-post %set ~] - (put:on-v-posts:c posts.channel id.c-post ~) + ca-core(posts.channel (put:on-v-posts:c posts.channel id.c-post ~)) :: ?(%add-react %del-react) =/ post (get:on-v-posts:c posts.channel id.c-post) - ?~ post `posts.channel - ?~ u.post `posts.channel - =/ [update=? reacts=v-reacts:c] (ca-c-react reacts.u.u.post c-post) - ?. update `posts.channel + ?~ post no-op + ?~ u.post no-op + =^ result=(each event:h tang) cor + =/ =event:h + :* %on-post %react u.u.post + ?: ?=(%del-react -.c-post) [p.c-post ~] + [p `q]:c-post + == + (run-hooks event nest 'react action blocked') + ?: ?=(%.n -.result) + ((slog p.result) no-op) + =/ new=c-post:c + ?> ?=([%on-post %react *] p.result) + ?~ react.p.result [%del-react id.c-post ship.p.result] + [%add-react id.c-post [ship u.react]:p.result] + =/ [update=? reacts=v-reacts:c] + %+ ca-c-react reacts.u.u.post + ?>(?=(?(%add-react %del-react) -.new) new) + ?. update no-op :- `[%post id.c-post %reacts reacts] - (put:on-v-posts:c posts.channel id.c-post ~ u.u.post(reacts reacts)) + %= ca-core + posts.channel + (put:on-v-posts:c posts.channel id.c-post ~ u.u.post(reacts reacts)) + == :: %reply =/ post (get:on-v-posts:c posts.channel id.c-post) - ?~ post `posts.channel - ?~ u.post `posts.channel + ?~ post no-op + ?~ u.post no-op =^ update=(unit u-post:c) replies.u.u.post - (ca-c-reply replies.u.u.post c-reply.c-post) - ?~ update `posts.channel + (ca-c-reply u.u.post c-reply.c-post) + ?~ update no-op :- `[%post id.c-post u.update] - (put:on-v-posts:c posts.channel id.c-post ~ u.u.post) + %= ca-core + posts.channel + (put:on-v-posts:c posts.channel id.c-post ~ u.u.post) + == == :: ++ ca-c-reply - |= [replies=v-replies:c =c-reply:c] - ^- [(unit u-post:c) _replies] + |= [parent=v-post:c =c-reply:c] + ^- [(unit u-post:c) v-replies:c] + =* replies replies.parent ?- -.c-reply %add ?> =(src.bowl author.memo.c-reply) @@ -718,6 +841,14 @@ $(now.bowl `@da`(add now.bowl ^~((div ~s1 (bex 16))))) =/ reply-seal=v-reply-seal:c [id ~] =/ new=v-reply:c [reply-seal 0 memo.c-reply] + =^ result=(each event:h tang) cor + =/ =event:h [%on-reply %add parent new] + (run-hooks event nest 'reply blocked') + ?: ?=(%.n -.result) + ((slog p.result) [~ replies]) + =. new + ?> ?=([%on-reply %add *] p.result) + reply.p.result :- `[%reply id %set ~ new] (put:on-v-replies:c replies id ~ new) :: @@ -726,8 +857,16 @@ ?~ reply `replies ?~ u.reply `replies ?> =(src.bowl author.u.u.reply) + =^ result=(each event:h tang) cor + =/ =event:h [%on-reply %edit parent u.u.reply memo.c-reply] + (run-hooks event nest 'edit blocked') + ?: ?=(%.n -.result) + ((slog p.result) [~ replies]) + =/ =memo:c + ?> ?=([%on-reply %edit *] p.result) + memo.p.result ::TODO could optimize and no-op if the edit is identical to current - =/ new=v-reply:c [-.u.u.reply +(rev.u.u.reply) memo.c-reply] + =/ new=v-reply:c [-.u.u.reply +(rev.u.u.reply) memo] :- `[%reply id.c-reply %set ~ new] (put:on-v-replies:c replies id.c-reply ~ new) :: @@ -736,6 +875,10 @@ ?~ reply `(put:on-v-replies:c replies id.c-reply ~) ?~ u.reply `replies ?> |(=(src.bowl author.u.u.reply) (is-admin:ca-perms src.bowl)) + =^ result=(each event:h tang) cor + =/ =event:h [%on-reply %del parent u.u.reply] + (run-hooks event nest 'delete blocked') + ?> =(& -.result) :- `[%reply id.c-reply %set ~] (put:on-v-replies:c replies id.c-reply ~) :: @@ -743,7 +886,22 @@ =/ reply (get:on-v-replies:c replies id.c-reply) ?~ reply `replies ?~ u.reply `replies - =/ [update=? reacts=v-reacts:c] (ca-c-react reacts.u.u.reply c-reply) + =^ result=(each event:h tang) cor + =/ =event:h + :* %on-reply %react parent u.u.reply + ?: ?=(%del-react -.c-reply) [p.c-reply ~] + [p `q]:c-reply + == + (run-hooks event nest 'delete blocked') + ?: ?=(%.n -.result) + ((slog p.result) [~ replies]) + =/ new=c-reply:c + ?> ?=([%on-reply %react *] p.result) + ?~ react.p.result [%del-react id.c-reply ship.p.result] + [%add-react id.c-reply [ship u.react]:p.result] + =/ [update=? reacts=v-reacts:c] + %+ ca-c-react reacts.u.u.reply + ?>(?=(?(%add-react %del-react) -.new) new) ?. update `replies :- `[%reply id.c-reply %reacts reacts] (put:on-v-replies:c replies id.c-reply ~ u.u.reply(reacts reacts)) @@ -753,7 +911,7 @@ |= [reacts=v-reacts:c =c-react:c] ^- [changed=? v-reacts:c] =/ =ship ?:(?=(%add-react -.c-react) p.c-react p.c-react) - ?> =(src.bowl ship) + ?> |(=(src.bowl our.bowl) =(src.bowl ship)) =/ new-react ?:(?=(%add-react -.c-react) `q.c-react ~) =/ [changed=? new-rev=@ud] =/ old-react (~(get by reacts) ship) @@ -842,4 +1000,287 @@ (said:utils nest plan posts.channel) (give %kick ~ ~) -- +++ scry-path + |= [=dude:gall =path] + %+ welp + /(scot %p our.bowl)/[dude]/(scot %da now.bowl) + path +++ get-hook-context + |= [channel=(unit [nest:c v-channel:c]) =config:h] + ^- context:h + =/ group + ?~ channel ~ + =* flag group.perm.perm.+.u.channel + %- some + ?. .^(? %gu (scry-path %groups /$)) *group-ui:g + ?. .^(? %gx (scry-path %groups /exists/(scot %p p.flag)/[q.flag]/noun)) + *group-ui:g + .^(group-ui:g %gx (scry-path %groups /groups/(scot %p p.flag)/[q.flag]/v1/noun)) + :* channel + group + v-channels + *hook:h :: we default this because each hook will replace with itself + config + [now our src eny]:bowl + == +:: +++ give-hook-response + |= =response:h + ^+ cor + (give %fact ~[/v0/hooks] hook-response-0+!>(response)) +++ ho-core + |_ [=id:h =hook:h gone=_|] + ++ ho-core . + ++ emit |=(=card ho-core(cor (^emit card))) + ++ emil |=(caz=(list card) ho-core(cor (^emil caz))) + ++ give |=(=gift:agent:gall ho-core(cor (^give gift))) + ++ ho-abet + %_ cor + hooks.hooks + ?:(gone (~(del by hooks.hooks) id) (~(put by hooks.hooks) id hook)) + == + :: + ++ ho-abed + |= i=id:h + ho-core(id i, hook (~(got by hooks.hooks) i)) + :: + ++ ho-add + |= [name=@t src=@t] + ^+ ho-core + =. id + =+ i=(end 7 eny.bowl) + |-(?:((~(has by hooks.hooks) i) $(i +(i)) i)) + =/ result=(each vase tang) + (compile:utils src) + =/ compiled + ?: ?=(%| -.result) + ((slog 'compilation result:' p.result) ~) + `p.result + =. hook [id %0 name *data:m src compiled !>(~) ~] + =. cor + =/ error=(unit tang) + ?:(?=(%& -.result) ~ `p.result) + (give-hook-response [%set id name src meta.hook error]) + ho-core + ++ ho-edit + |= [name=(unit @t) src=(unit @t) meta=(unit data:m)] + =? src.hook ?=(^ src) u.src + =/ result=(each vase tang) + (compile:utils src.hook) + ?: ?=(%| -.result) + =. cor + %- give-hook-response + [%set id name.hook src.hook meta.hook `p.result] + ho-core + =? name.hook ?=(^ name) u.name + =? meta.hook ?=(^ meta) u.meta + =. compiled.hook `p.result + =. cor + %- give-hook-response + [%set id name.hook src.hook meta.hook ~] + ho-core + :: + ++ ho-del + =. gone & + =. cor + %+ roll + ~(tap by (~(gut by crons.hooks) id *(map origin:h cron:h))) + |= [[=origin:h =cron:h] cr=_cor] + (unschedule-cron:cr origin cron) + =. crons.hooks (~(del by crons.hooks) id) + =. order.hooks + %+ roll + ~(tap by order.hooks) + |= [[=nest:c ids=(list id:h)] or=(map nest:c (list id:h))] + =- (~(put by or) nest -) + (skip ids |=(i=id:h =(id i))) + =. delayed.hooks + %+ roll + ~(tap by delayed.hooks) + |= [[=delay-id:h d=[* delayed-hook:h]] dh=_delayed.hooks] + ?. =(id hook.d) dh + (~(del by dh) delay-id) + =. cor (give-hook-response [%gone id]) + ho-core + ++ ho-configure + |= [=nest:c =config:h] + ^+ ho-core + =. config.hook (~(put by config.hook) nest config) + =. cor (give-hook-response [%config id nest config]) + ho-core + ++ ho-wait + |= [=origin:h schedule=$@(@dr schedule:h) =config:h] + ^+ ho-core + =/ schedule + ?: ?=(@ schedule) [now.bowl schedule] + schedule + =/ crons (~(gut by crons.hooks) id *(map origin:h cron:h)) + =/ =cron:h [id schedule config] + =. crons.hooks + =- (~(put by crons.hooks) id.hook -) + (~(put by crons) origin cron) + =. cor (schedule-cron origin cron) + =. cor (give-hook-response [%wait id origin schedule config]) + ho-core + ++ ho-rest + |= =origin:h + ^+ ho-core + =/ crons (~(got by crons.hooks) id) + =/ cron (~(got by crons) origin) + =. crons.hooks + (~(put by crons.hooks) id (~(del by crons) origin)) + =. cor (unschedule-cron origin cron) + =. cor (give-hook-response [%rest id origin]) + ho-core + ++ ho-run-single + |= [=event:h prefix=tape =origin:h =config:h] + =/ channel + ?@ origin ~ + ?~ ch=(~(get by v-channels) origin) ~ + `[origin u.ch] + =/ =context:h (get-hook-context channel config) + =/ return=(unit return:h) + (run-hook:utils [event context(hook hook)] hook) + ?~ return + %- (slog (crip "{prefix} {} failed, running on {}") ~) + ho-core + %- (slog (crip "{prefix} {} ran on {}") ~) + =. hook hook(state new-state.u.return) + =. cor (run-hook-effects effects.u.return origin) + ho-core + -- +++ run-hooks + |= [=event:h =nest:c default=cord] + ^- [(each event:h tang) _cor] + =; [result=(each event:h tang) effects=(list effect:h)] + [result (run-hook-effects effects nest)] + =/ current-event event + =| effects=(list effect:h) + =/ order (~(gut by order.hooks) nest ~) + =/ channel `[nest (~(got by v-channels) nest)] + =/ =context:h (get-hook-context channel *config:h) + |- + ?~ order + [&+current-event effects] + =* next $(order t.order) + =/ hook (~(got by hooks.hooks) i.order) + =/ ctx context(hook hook, config (~(gut by config.hook) nest ~)) + =/ return=(unit return:h) + (run-hook:utils [current-event ctx] hook) + ?~ return next + =* result result.u.return + =. effects (weld effects effects.u.return) + =. hooks.hooks (~(put by hooks.hooks) i.order hook(state new-state.u.return)) + ?: ?=(%denied -.result) + [|+~[(fall msg.result default)] effects] + =. current-event new.result + next +++ wakeup-hook + |= =(pole knot) + ^+ cor + ?+ pole ~|(bad-arvo-take/pole !!) + [%delayed id=@ ~] + =/ =id:h (slav %uv id.pole) + ?~ delay=(~(get by delayed.hooks) id) cor + :: make sure we clean up + =. delayed.hooks (~(del by delayed.hooks) id) + :: ignore premature fires + ?: (lth now.bowl fires-at.u.delay) cor + =* origin origin.u.delay + =/ hook (~(got by hooks.hooks) hook.u.delay) + =/ config ?@(origin ~ (~(gut by config.hook) origin ~)) + =/ args [[%wake +.u.delay] "delayed hook" origin config] + ho-abet:(ho-run-single:(ho-abed:ho-core hook.u.delay) args) + :: + [%cron id=@ kind=?(%chat %diary %heap) ship=@ name=@ ~] + =/ =id:h (slav %uv id.pole) + =/ =origin:h [kind.pole (slav %p ship.pole) name.pole] + :: if unscheduled, ignore + ?~ crons=(~(get by crons.hooks) id) cor + ?~ cron=(~(get by u.crons) origin) cor + :: ignore premature fires + ?: (lth now.bowl next.schedule.u.cron) cor + =. next.schedule.u.cron + :: we don't want to run the cron for every iteration it would + :: have run 'offline', so we check here to make sure that the + :: next fire time is in the future + =/ next (add [next repeat]:schedule.u.cron) + |- + ?: (gte next now.bowl) next + $(next (add next repeat.schedule.u.cron)) + =. crons.hooks + %+ ~(put by crons.hooks) id + (~(put by u.crons) origin u.cron) + =. cor + (schedule-cron origin u.cron) + =/ args [[%cron ~] "cron job" origin config.u.cron] + ho-abet:(ho-run-single:(ho-abed:ho-core hook.u.cron) args) + == +++ schedule-cron + |= [=origin:h =cron:h] + ^+ cor + =/ wire + %+ welp /hooks/cron/(scot %uv hook.cron) + ?@ origin ~ + /[kind.origin]/(scot %p ship.origin)/[name.origin] + (emit [%pass wire %arvo %b %wait next.schedule.cron]) +++ unschedule-cron + |= [=origin:h =cron:h] + =/ wire + %+ welp /hooks/cron/(scot %uv hook.cron) + ?@ origin ~ + /[kind.origin]/(scot %p ship.origin)/[name.origin] + (emit [%pass wire %arvo %b %rest next.schedule.cron]) +++ schedule-delay + |= dh=delayed-hook:h + =/ =wire /hooks/delayed/(scot %uv id.dh) + (emit [%pass wire %arvo %b %wait fires-at.dh]) +++ unschedule-delay + |= =id:h + ^+ cor + ?~ previous=(~(get by delayed.hooks) id) cor + =/ =wire /hooks/delayed/(scot %uv id.u.previous) + (emit [%pass wire %arvo %b %rest fires-at.u.previous]) +++ run-hook-effects + |= [effects=(list effect:h) =origin:h] + ^+ cor + |- + ?~ effects + cor + =/ =effect:h i.effects + =; new-cor=_cor + =. cor new-cor + $(effects t.effects) + ?- -.effect + %channels + =/ =cage channel-action+!>(a-channels.effect) + (emit [%pass /hooks/effect %agent [our.bowl %channels] %poke cage]) + :: + %groups + =/ =cage group-action-3+!>(action.effect) + (emit [%pass /hooks/effect %agent [our.bowl %groups] %poke cage]) + :: + %activity + =/ =cage activity-action+!>(action.effect) + (emit [%pass /hooks/effect %agent [our.bowl %activity] %poke cage]) + :: + %dm + =/ =cage chat-dm-action+!>(action.effect) + (emit [%pass /hooks/effect %agent [our.bowl %chat] %poke cage]) + :: + %club + =/ =cage chat-club-action+!>(action.effect) + (emit [%pass /hooks/effect %agent [our.bowl %chat] %poke cage]) + :: + %contacts + =/ =cage contacts-action-1+!>(action.effect) + (emit [%pass /hooks/effect %agent [our.bowl %contacts] %poke cage]) + :: + %wait + =/ =wire /hooks/delayed/(scot %uv id.effect) + =. cor (unschedule-delay id.effect) + =. delayed.hooks + (~(put by delayed.hooks) id.effect [origin +.effect]) + (schedule-delay +.effect) + == -- diff --git a/desk/app/channels.hoon b/desk/app/channels.hoon index 57e365c395..3ea8afa5aa 100644 --- a/desk/app/channels.hoon +++ b/desk/app/channels.hoon @@ -741,6 +741,9 @@ :: [%x %v2 %channels full=?(~ [%full ~])] ``channels-2+!>((uv-channels-2:utils v-channels ?=(^ full.pole))) + :: + [%x %v3 %v-channels ~] + ``noun+!>(v-channels) :: [%x %v3 %channels full=?(~ [%full ~])] ``channels-3+!>((uv-channels-3:utils v-channels ?=(^ full.pole))) @@ -2383,7 +2386,7 @@ |= sects=(set sect:g) =/ =flag:g group.perm.perm.channel =/ exists-path - (scry-path %groups /exists/(scot %p p.flag)/[q.flag]) + (scry-path %groups /exists/(scot %p p.flag)/[q.flag]/noun) =+ .^(exists=? %gx exists-path) ?. exists ca-core =/ =path diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index c574fb2399..dce125ff4d 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v6.fu63g.0sq11.err1r.f2b33.8neao.glob' 0v6.fu63g.0sq11.err1r.f2b33.8neao] + glob-http+['https://bootstrap.urbit.org/glob-0v1.q6jaf.bme3f.povqt.eo08j.d6qtq.glob' 0v1.q6jaf.bme3f.povqt.eo08j.d6qtq] base+'groups' version+[6 4 2] website+'https://tlon.io' diff --git a/desk/gen/hooks/confirm.hoon b/desk/gen/hooks/confirm.hoon new file mode 100644 index 0000000000..ea6c1f228c --- /dev/null +++ b/desk/gen/hooks/confirm.hoon @@ -0,0 +1,19 @@ +/- h=hooks, c=channels +:- %say +|= $: [now=@da eny=@uvJ =beak] + [[=event:h =context:h ~] ~] + == +:- %noun +^- outcome:h +=- &+[[[%allowed event] -] state.hook.context] +^- (list effect:h) +?. ?=(?(%delay %on-post) -.event) ~ +?: ?=(%delay -.event) + =/ =nest:c [%chat ~bospur-davmyl-nocsyx-lassul %welcome-8458] + =+ !<(trigger=event:h data.event) + ?. ?=([%on-post %add *] trigger) ~ + =* post post.trigger + =/ =c-react:c [%add-react id.post author.post ':thumbs-up:'] + ~[[%channels %channel nest %post c-react]] +=/ id (rsh [3 48] eny.context) +~[[%delay id id.hook.context ~s30 !>(event)]] \ No newline at end of file diff --git a/desk/gen/hooks/truncate.hoon b/desk/gen/hooks/truncate.hoon new file mode 100644 index 0000000000..538738a432 --- /dev/null +++ b/desk/gen/hooks/truncate.hoon @@ -0,0 +1,97 @@ +/- h=hooks, c=channels +:- %say +|= $: [now=@da eny=@uvJ =beak] + [[=event:h ~] ~] + == +:- %noun +^- outcome:h +=| count=@ud +=/ max 140 +=/ new-content=story:c ~ +=* no-op &+[[[%allowed event] ~] !>(~)] +?. ?=(%on-post -.event) no-op +=* on-post +.event +?. ?=(?(%add %edit) -.on-post) no-op +=/ verses + ?- -.on-post + %add content.essay.post.on-post + %edit content.essay.on-post + == +|^ +:: made it to the end +=* return + =- &+[[[%allowed -] ~] !>(~)] + ?- event + [%on-post %add *] event(content.essay.post new-content) + [%on-post %edit *] event(content.essay new-content) + == +?~ verses return +?: (gte count max) return +=* next $(verses t.verses) +=/ verse i.verses +:: remove blocks +?: ?=(%block -.verse) next +=/ [new-inlines=(list inline:c) new-count=@ud] + (run-list p.verse count) +$(new-content (snoc new-content [%inline new-inlines]), verses t.verses, count new-count) +++ run-list + |= [inlines=(list inline:c) count=@ud] + ^- [(list inline:c) @ud] + =/ new-inlines=(list inline:c) ~ + |- + ?~ inlines + :: made it all the way through + [new-inlines count] + =* next $(inlines t.inlines) + =/ inline i.inlines + ?: (gte count max) + [new-inlines count] + ?@ inline + =/ new-string (trim-cord inline count) + ?~ new-string $(inlines ~) ::done + $(new-inlines (snoc new-inlines u.new-string), inlines t.inlines, count (add count (met 3 u.new-string))) + =/ [new-inline=(unit inline:c) new-count=@ud] + (run-special-inlines inline count) + ?~ new-inline $(inlines ~) ::done + $(new-inlines (snoc new-inlines u.new-inline), inlines t.inlines, count new-count) +++ run-special-inlines + |= [=inline:c count=@ud] + ^- [(unit inline:c) @ud] + ?+ -.inline [~ count] + %break [`inline +(count)] + :: + %ship + ?: (gth (add count 14) max) [~ count] + [`inline (add count 14)] + :: + %link + =/ new-string=(unit cord) (trim-cord q.inline count) + ?~ new-string [~ count] + =/ new-inline=inline:c inline(q u.new-string) + [(some new-inline) (add count (met 3 u.new-string))] + :: + %inline-code + =/ new-string=(unit cord) (trim-cord p.inline count) + ?~ new-string [~ count] + [(some inline(p u.new-string)) (add count (met 3 u.new-string))] + :: + ?(%italics %bold %strike %blockquote) + =/ [new-inlines=(list inline:c) new-count=@ud] (run-list p.inline count) + ?~ new-inlines [~ count] + [(some inline(p new-inlines)) new-count] + == +++ trim-cord + |= [=cord count=@ud] + ^- (unit ^cord) + =/ string (trip cord) + =/ length (lent string) + =/ total (add length count) + ?: (gth total max) + :: truncate + =/ remainder (sub total max) + :: no room for anything + ?: =(length remainder) ~ + =/ new-length (sub length remainder) + `(crip (scag new-length string)) + `cord +-- \ No newline at end of file diff --git a/desk/lib/channel-utils.hoon b/desk/lib/channel-utils.hoon index 26e070491c..7bdcc0987b 100644 --- a/desk/lib/channel-utils.hoon +++ b/desk/lib/channel-utils.hoon @@ -1,4 +1,4 @@ -/- c=channels, g=groups, ci=cite +/- c=channels, g=groups, ci=cite, h=hooks :: convert a post to a preview for a "said" response :: |% @@ -676,4 +676,31 @@ ;br; == -- +++ subject ^~(!>(..compile)) +++ compile + |= src=@t + ^- (each vase tang) + =/ tonk=(each vase tang) + =/ vex=(like hoon) ((full vest) [0 0] (trip src)) + ?~ q.vex |+~[leaf+"\{{} {}}" 'syntax error'] + %- mule + |.((~(mint ut p:subject) %noun p.u.q.vex)) + %- (slog (crip "parsed hoon: {<-.tonk>}") ~) + ?: ?=(%| -.tonk) + %- (slog 'returning error' p.tonk) + tonk + &+p.tonk +++ run-hook + |= [=args:h =hook:h] + ^- (unit return:h) + %- (slog (crip "running hook: {} {}") ~) + %- ?~ channel.context.args same + (slog (crip "on channel: {}") ~) + ?~ compiled.hook ~ + =/ gate [p.u.compiled.hook .*(q:subject q.u.compiled.hook)] + =+ !<(=outcome:h (slam gate !>(args))) + %- (slog (crip "{(trip name.hook)} {} hook run:") ~) + %- (slog >outcome< ~) + ?: ?=(%.y -.outcome) `p.outcome + ((slog 'hook failed:' p.outcome) ~) -- diff --git a/desk/lib/hooks-json.hoon b/desk/lib/hooks-json.hoon new file mode 100644 index 0000000000..ece631a223 --- /dev/null +++ b/desk/lib/hooks-json.hoon @@ -0,0 +1,214 @@ +/- h=hooks, c=channels, m=meta +/+ cj=channel-json, gj=groups-json +=* z ..zuse +|% +++ enjs + =, enjs:format + |% + ++ id + |= i=id:h + s+(scot %uv i) + ++ hooks + |= hks=hooks:h + %- pairs + :~ hooks+(hook-map hooks.hks) + order+(order order.hks) + crons+(crons crons.hks) + == + :: + ++ hook-map + |= hks=(map id:h hook:h) + %- pairs + %+ turn + ~(tap by hks) + |= [=id:h hk=hook:h] + [(scot %uv id) (hook hk)] + :: + ++ hook + |= hk=hook:h + %- pairs + :~ id+(id id.hk) + version+s+`@tas`version.hk + name+s+name.hk + meta+(meta:enjs:gj meta.hk) + src+s+src.hk + compiled+b+?=(^ compiled.hk) + config+(config-map config.hk) + == + :: + ++ config-map + |= cfg=(map nest:c config:h) + %- pairs + %+ turn + ~(tap by cfg) + |= [=nest:c con=config:h] + [(nest-cord:enjs:cj nest) (config con)] + ++ config + |= con=config:h + %- pairs + %+ turn + ~(tap by con) + |= [key=@t noun=*] + [key s+(scot %uw `@uw`(jam noun))] + ++ order + |= ord=(map nest:c (list id:h)) + %- pairs + %+ turn + ~(tap by ord) + |= [=nest:c seq=(list id:h)] + [(nest-cord:enjs:cj nest) a+(turn seq id)] + ++ crons + |= crs=(map id:h (map origin:h cron:h)) + %- pairs + %+ turn + ~(tap by crs) + |= [=id:h cr=(map origin:h cron:h)] + [(scot %uv id) (cron-map cr)] + ++ cron-map + |= cr=(map origin:h cron:h) + %- pairs + %+ turn + ~(tap by cr) + |= [=origin:h crn=cron:h] + :_ (cron crn) + ?@(origin 'global' (nest-cord:enjs:cj origin)) + ++ cron + |= crn=cron:h + %- pairs + :~ hook+(id hook.crn) + schedule+(schedule schedule.crn) + config+(config config.crn) + == + ++ schedule + |= sch=schedule:h + %- pairs + :~ next+s+(scot %da next.sch) + repeat+s+(scot %dr repeat.sch) + == + ++ response + |= r=response:h + %+ frond -.r + ?- -.r + %set (set-rsp +.r) + %gone (id id.r) + %order (order-rsp +.r) + %config (config-rsp +.r) + %wait (wait-rsp +.r) + %rest (rest-rsp +.r) + == + ++ set-rsp + |= [i=id:h name=@t src=@t meta=data:m error=(unit ^tang)] + %- pairs + :~ id+(id i) + name+s+name + src+s+src + meta+(meta:enjs:gj meta) + error+?~(error ~ (tang u.error)) + == + ++ order-rsp + |= [=nest:c seq=(list id:h)] + %- pairs + :~ nest+(nest:enjs:cj nest) + seq+a+(turn seq id) + == + ++ config-rsp + |= [i=id:h =nest:c con=config:h] + %- pairs + :~ id+(id i) + nest+(nest:enjs:cj nest) + config+(config con) + == + ++ wait-rsp + |= [i=id:h or=origin:h sch=$@(@dr schedule:h) con=config:h] + %- pairs + :~ id+(id i) + origin+s+?~(or 'global' (nest-cord:enjs:cj or)) + schedule+?@(sch s+(scot %dr sch) (schedule sch)) + config+(config con) + == + ++ rest-rsp + |= [i=id:h or=origin:h] + %- pairs + :~ id+(id i) + origin+s+?~(or 'global' (nest-cord:enjs:cj or)) + == + ++ tang + |= t=^tang + :- %s + %- crip + %+ roll t + |= [tk=^tank tp=^tape] + ~! tk + =/ next=^tape ~(ram re tk) + (welp (snoc tp '\0a') next) + -- +:: +++ dejs + =, dejs:format + |% + ++ id (se %uv) + ++ action + %- of + :~ add/add + edit/edit + del/id + order/order + config/config + wait/wait + rest/rest + == + ++ add + %- ot + :~ name/so + src/so + == + ++ edit + %- ot + :~ id/id + name/(mu so) + src/(mu so) + meta/(mu meta:dejs:gj) + == + ++ order + %- ot + :~ nest/nest:dejs:cj + seq/(ar id) + == + ++ config + %- ot + :~ id/id + nest/nest:dejs:cj + config/(om noun) + == + ++ noun + |= j=json + (cue ((se %uw) j)) + ++ origin + |= j=json + ?~(j ~ (nest:dejs:cj j)) + ++ wait + %- ot + :~ id/id + origin/origin + schedule/schedule + config/(om noun) + == + ++ rest + %- ot + :~ id/id + origin/origin + == + ++ schedule + |= j=json + ?+ j !! + [%s *] ((se %dr) j) + :: + [%o *] + %. j + %- ot + :~ next/(se %da) + repeat/(se %dr) + == + == + -- +-- diff --git a/desk/mar/hook/action-0.hoon b/desk/mar/hook/action-0.hoon new file mode 100644 index 0000000000..45838b60b0 --- /dev/null +++ b/desk/mar/hook/action-0.hoon @@ -0,0 +1,14 @@ +/- h=hooks, c=channels +/+ hj=hooks-json +|_ =action:h +++ grad %noun +++ grow + |% + ++ noun action + -- +++ grab + |% + ++ noun action:h + ++ json action:dejs:hj + -- +-- diff --git a/desk/mar/hook/full.hoon b/desk/mar/hook/full.hoon new file mode 100644 index 0000000000..6d9dd00ba5 --- /dev/null +++ b/desk/mar/hook/full.hoon @@ -0,0 +1,14 @@ +/- h=hooks, c=channels +/+ hj=hooks-json +|_ =hooks:h +++ grad %noun +++ grow + |% + ++ noun hooks + ++ json (hooks:enjs:hj hooks) + -- +++ grab + |% + ++ noun hooks:h + -- +-- diff --git a/desk/mar/hook/response-0.hoon b/desk/mar/hook/response-0.hoon new file mode 100644 index 0000000000..e4eac8ff34 --- /dev/null +++ b/desk/mar/hook/response-0.hoon @@ -0,0 +1,14 @@ +/- h=hooks, c=channels +/+ hj=hooks-json +|_ =response:h +++ grad %noun +++ grow + |% + ++ noun response + ++ json (response:enjs:hj response) + -- +++ grab + |% + ++ noun response:h + -- +-- diff --git a/desk/sur/hooks.hoon b/desk/sur/hooks.hoon new file mode 100644 index 0000000000..984c08f4d1 --- /dev/null +++ b/desk/sur/hooks.hoon @@ -0,0 +1,172 @@ +/- *channels, g=groups, a=activity, ch=chat, co=contacts, m=meta +|% +:: $id: a unique identifier for a hook ++$ id @uv +:: +:: $hook: a pure function that runs on triggers in a channel +:: +:: $id: a unique identifier for the hook +:: $name: a human-readable name for the hook +:: $version: the version the hook was compiled with +:: $src: the source code of the hook +:: $compiled: the compiled version of the hook +:: $state: the current state of the hook +:: $config: any configuration data for the hook +:: +++ hook + $: =id + version=%0 + name=@t + meta=data:m + src=@t + compiled=(unit vase) + state=vase + config=(map nest config) + == +:: $hooks: collection of hooks, the order they should be run in, and +:: any delayed hooks that need to be run +++ hooks + $: hooks=(map id hook) + order=(map nest (list id)) + crons=(map id (map origin cron)) + delayed=(map delay-id [=origin delayed-hook]) + == ++$ origin $@(~ nest) ++$ delay-id id ++$ schedule [next=@da repeat=@dr] ++$ cron + $: hook=id + =schedule + =config + == +:: $delayed-hook: metadata for when a delayed hook fires from the timer ++$ delayed-hook + $: id=delay-id + hook=id + data=vase + fires-at=time + == +:: ++$ config (map @t *) ++$ action + $% [%add name=@t src=@t] + [%edit =id name=(unit @t) src=(unit @t) meta=(unit data:m)] + [%del =id] + [%order =nest seq=(list id)] + [%config =id =nest =config] + [%wait =id =origin schedule=$@(@dr schedule) =config] + [%rest =id =origin] + == ++$ response + $% [%set =id name=@t src=@t meta=data:m error=(unit tang)] + [%gone =id] + [%order =nest seq=(list id)] + [%config =id =nest =config] + [%wait =id =origin schedule=$@(@dr schedule) =config] + [%rest =id =origin] + == +:: $context: ambient state that a hook should know about not +:: necessarily tied to a specific event +:: +:: $channel: the channel that the hook is operating on +:: $group: the group that the channel belongs to +:: $channels: all the channels in the group +:: $hook: the hook that's running +:: $config: the configuration data for this instance of the hook +:: $now: the current time +:: $our: the ship that the hook is running on +:: $src: the ship that triggered the hook +:: $eny: entropy for random number generation or key derivation +:: ++$ context + $: channel=(unit [=nest v-channel]) + group=(unit group-ui:g) + channels=v-channels + =hook + =config + now=time + our=ship + src=ship + eny=@ + == +:: +:: $event: the data associated with the trigger of a hook +:: +:: $on-post: a post was added, edited, deleted, or reacted to +:: $on-reply: a reply was added, edited, deleted, or reacted to +:: $cron: a scheduled wake-up +:: $wake: a delayed invocation of the hook called with metadata about +:: when it fired, its id, and the event it should run with +:: ++$ event + $% [%on-post on-post] + [%on-reply on-reply] + [%cron ~] + [%wake delayed-hook] + == +:: +:: $on-post: a hook event that fires when posts are interacted with ++$ on-post + $% [%add post=v-post] + [%edit original=v-post =essay] + [%del original=v-post] + [%react post=v-post =ship react=(unit react)] + == +:: +:: $on-reply: a hook event that fires when replies are interacted with ++$ on-reply + $% [%add parent=v-post reply=v-reply] + [%edit parent=v-post original=v-reply =memo] + [%del parent=v-post original=v-reply] + [%react parent=v-post reply=v-reply =ship react=(unit react)] + == +:: +:: $args: the arguments passed to a hook ++$ args + $: =event + =context + == +:: $outcome: the result of a hook running ++$ outcome (each return tang) +:: +:: $return: the data returned from a hook +:: +:: $result: whether the action was allowed or denied and any +:: transformed values +:: $actions: any actions that should be taken on other agents or delay +:: $new-state: the new state of the hook after running +:: ++$ return + $: $: =result + effects=(list effect) + == + new-state=vase + == +:: +:: $result: whether to allow the action, and any transformations to +:: the event +:: +:: $allowed: represents the action being allowed to go through, and +:: the new value of the action +:: $denied: represents the action being denied along with the reason +:: that the action was denied +:: ++$ result + $% [%allowed new=event] + [%denied msg=(unit cord)] + == +:: +:: $effect: an effect that a hook can have, limited to agents in +:: the %groups desk. $delay is a special effect that will wake +:: up the same hook at a later time. ++$ effect + $% [%channels =a-channels] + [%groups =action:g] + [%activity =action:a] + [%dm =action:dm:ch] + [%club =action:club:ch] + [%contacts =action:co] + [%wait delayed-hook] + == +:: +-- \ No newline at end of file diff --git a/desk/ted/hook/add.hoon b/desk/ted/hook/add.hoon new file mode 100644 index 0000000000..2e66c7b2ed --- /dev/null +++ b/desk/ted/hook/add.hoon @@ -0,0 +1,20 @@ +/- spider, h=hooks +/+ s=strandio +=, strand=strand:spider +^- thread:spider +|= arg=vase +=/ m (strand ,vase) +^- form:m +=+ !<([~ name=@t src=@t] arg) +;< our=@p bind:m get-our:s +;< ~ bind:m (watch:s /responses [our %channels-server] /v0/hooks) +=/ =cage hook-action-0+!>(`action:h`[%add name src]) +;< ~ bind:m (poke-our:s %channels-server cage) +;< =^cage bind:m (take-fact:s /responses) +?> ?=(%hook-response-0 p.cage) +=+ !<(=response:h q.cage) +?> ?=(%set -.response) +%- (slog (crip "hook {} added with id {}") ~) +?~ error.response (pure:m !>(~)) +%- (slog 'compilation error:' u.error.response) +(pure:m !>(~)) \ No newline at end of file diff --git a/desk/ted/hook/configure.hoon b/desk/ted/hook/configure.hoon new file mode 100644 index 0000000000..20927f9f85 --- /dev/null +++ b/desk/ted/hook/configure.hoon @@ -0,0 +1,18 @@ +/- spider, h=hooks, c=channels +/+ s=strandio +=, strand=strand:spider +^- thread:spider +|= arg=vase +=/ m (strand ,vase) +^- form:m +=+ !<([~ =id:h =nest:c =config:h] arg) +;< our=@p bind:m get-our:s +;< ~ bind:m (watch:s /responses [our %channels-server] /v0/hooks) +=/ =cage hook-action-0+!>(`action:h`[%config id nest config]) +;< ~ bind:m (poke-our:s %channels-server cage) +;< =^cage bind:m (take-fact:s /responses) +?> ?=(%hook-response-0 p.cage) +=+ !<(=response:h q.cage) +?> ?=(%config -.response) +%- (slog (crip "hook {} running on {} configured") ~) +(pure:m !>(~)) \ No newline at end of file diff --git a/desk/ted/hook/del.hoon b/desk/ted/hook/del.hoon new file mode 100644 index 0000000000..b955c86621 --- /dev/null +++ b/desk/ted/hook/del.hoon @@ -0,0 +1,19 @@ +/- spider, h=hooks +/+ s=strandio +=, strand=strand:spider +^- thread:spider +|= arg=vase +=/ m (strand ,vase) +^- form:m +=+ !<([~ =id:h] arg) +;< our=@p bind:m get-our:s +;< ~ bind:m (watch:s /responses [our %channels-server] /v0/hooks) +=/ =cage hook-action-0+!>(`action:h`[%del id]) +;< ~ bind:m (poke-our:s %channels-server cage) +;< =^cage bind:m (take-fact:s /responses) +?> ?=(%hook-response-0 p.cage) +=+ !<(=response:h q.cage) +?> ?=(%gone -.response) +?> =(id id.response) +%- (slog (crip "hook {} deleted") ~) +(pure:m !>(~)) \ No newline at end of file diff --git a/desk/ted/hook/edit.hoon b/desk/ted/hook/edit.hoon new file mode 100644 index 0000000000..04fbbdc369 --- /dev/null +++ b/desk/ted/hook/edit.hoon @@ -0,0 +1,22 @@ +/- spider, h=hooks, meta +/+ s=strandio +=, strand=strand:spider +^- thread:spider +|= arg=vase +=/ m (strand ,vase) +^- form:m +=+ !<([~ =id:h name=(unit @t) src=(unit @t) meta=(unit data:meta)] arg) +;< our=@p bind:m get-our:s +;< ~ bind:m (watch:s /responses [our %channels-server] /v0/hooks) +=/ =cage hook-action-0+!>(`action:h`[%edit id name src meta]) +;< ~ bind:m (poke-our:s %channels-server cage) +;< =^cage bind:m (take-fact:s /responses) +?> ?=(%hook-response-0 p.cage) +=+ !<(=response:h q.cage) +?> ?=(%set -.response) +?~ error.response + %- (slog (crip "hook {} edited successfully") ~) + (pure:m !>(~)) +%- (slog (crip "hook {} edited") ~) +%- (slog 'compilation error:' u.error.response) +(pure:m !>(~)) \ No newline at end of file diff --git a/desk/ted/hook/order.hoon b/desk/ted/hook/order.hoon new file mode 100644 index 0000000000..d31736f891 --- /dev/null +++ b/desk/ted/hook/order.hoon @@ -0,0 +1,19 @@ +/- spider, h=hooks, c=channels +/+ s=strandio +=, strand=strand:spider +^- thread:spider +|= arg=vase +=/ m (strand ,vase) +^- form:m +=+ !<([~ =nest:c seq=(list id:h)] arg) +;< our=@p bind:m get-our:s +;< ~ bind:m (watch:s /responses [our %channels-server] /v0/hooks) +=/ =cage hook-action-0+!>(`action:h`[%order nest seq]) +;< ~ bind:m (poke-our:s %channels-server cage) +;< =^cage bind:m (take-fact:s /responses) +?> ?=(%hook-response-0 p.cage) +=+ !<(=response:h q.cage) +?> ?=(%order -.response) +%- (slog (crip "new hook order for {}") ~) +~& seq.response +(pure:m !>(~)) \ No newline at end of file diff --git a/desk/ted/hook/run.hoon b/desk/ted/hook/run.hoon new file mode 100644 index 0000000000..1efcc6e1d5 --- /dev/null +++ b/desk/ted/hook/run.hoon @@ -0,0 +1,67 @@ +/- spider, h=hooks, c=channels, g=groups +/+ s=strandio, utils=channel-utils +=, strand=strand:spider +^- thread:spider +|= arg=vase +|^ +=/ m (strand ,vase) +^- form:m +=+ !<([~ =event:h =context-option src=@t] arg) +;< our=@p bind:m get-our:s +=/ compiled=(each vase tang) (compile:utils src) +?. ?=(%& -.compiled) + %- (slog 'compilation error:' p.compiled) + (pure:m !>(~)) +;< ctx=context:h bind:m (get-context context-option) +=/ gate [p.p.compiled .*(q:subject:utils q.p.compiled)] +=+ !<(=outcome:h (slam gate !>([event ctx]))) +?: ?=(%.y -.outcome) + %- (slog 'hook ran successfully' ~) + (pure:m !>(p.outcome)) +%- (slog 'hook failed:' p.outcome) +(pure:m !>(~)) ++$ event-option + $% [%ref path=@t] + [%event event:h] + == ++$ context-option + $% [%origin =origin:h state=(unit vase) config=(unit config:h)] + [%context =context:h] + == +++ get-context + |= =context-option + =/ m (strand ,context:h) + ^- form:m + ?: ?=(%context -.context-option) (pure:m context.context-option) + =/ [=origin:h state=(unit vase) config=(unit config:h)] +.context-option + ;< =v-channels:c bind:m + (scry:s v-channels:c /gx/channels/v3/v-channels/noun) + =/ channel=(unit [=nest:c v-channel:c]) + ?~ origin ~ + `[origin (~(gut by v-channels) origin *v-channel:c)] + ;< group=(unit group-ui:g) bind:m + =/ n (strand (unit group-ui:g)) + ?~ channel (pure:n ~) + =* flag group.perm.perm.u.channel + ;< live=? bind:n (scry:s ? /gu/groups/$) + ?. live (pure:n `*group-ui:g) + ;< exists=? bind:n + (scry:s ? /gx/groups/exists/(scot %p p.flag)/[q.flag]/noun) + ?. exists (pure:n `*group-ui:g) + ;< =group-ui:g bind:n + (scry:s group-ui:g /gx/groups/groups/(scot %p p.flag)/[q.flag]/v1/noun) + (pure:n (some group-ui)) + =/ cfg=config:h + ?~ config ~ + u.config + ;< =bowl:spider bind:m get-bowl:s + =/ hook *hook:h + %- pure:m + :* channel + group + v-channels + hook(state ?~(state !>(~) u.state)) + ?~(origin ~ (~(gut by config.hook) origin ~)) + [now our src eny]:bowl + == +-- diff --git a/desk/ted/hook/schedule.hoon b/desk/ted/hook/schedule.hoon new file mode 100644 index 0000000000..be3a15225a --- /dev/null +++ b/desk/ted/hook/schedule.hoon @@ -0,0 +1,36 @@ +/- spider, h=hooks +/+ s=strandio +=, strand=strand:spider +^- thread:spider +|= arg=vase +=/ m (strand ,vase) +^- form:m +|^ +=+ !<([~ =id:h =origin:h =action] arg) +;< our=@p bind:m get-our:s +;< ~ bind:m (watch:s /responses [our %channels-server] /v0/hooks) +=/ =cage + :- %hook-action-0 + !> + ^- action:h + ?: ?=(%stop -.action) [%rest id origin] + [%wait id origin +.action] +;< ~ bind:m (poke-our:s %channels-server cage) +;< =^cage bind:m (take-fact:s /responses) +?> ?=(%hook-response-0 p.cage) +=+ !<(=response:h q.cage) +?> ?=(?(%wait %rest) -.response) +?: ?=(%rest -.response) + %- (slog (crip "stopped scheduled hook {} running on {}") ~) + (pure:m !>(~)) +;< now=time bind:m get-time:s +=/ fires-at + ?@ schedule.response (add now schedule.response) + next.schedule.response +%- (slog (crip "starting hook {}, scheduled to run on {} at {}") ~) +(pure:m !>(~)) ++$ action + $% [%stop ~] + [%start schedule=$@(@dr schedule:h) =config:h] + == +-- \ No newline at end of file diff --git a/packages/app/features/contacts/AddContactsScreen.tsx b/packages/app/features/contacts/AddContactsScreen.tsx new file mode 100644 index 0000000000..c38d0de685 --- /dev/null +++ b/packages/app/features/contacts/AddContactsScreen.tsx @@ -0,0 +1,21 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import * as store from '@tloncorp/shared/store'; +import { AddContactsView } from '@tloncorp/ui'; +import { useCallback } from 'react'; + +import type { RootStackParamList } from '../../navigation/types'; + +type Props = NativeStackScreenProps; + +export function AddContactsScreen(props: Props) { + const handleAddContacts = useCallback((addIds: string[]) => { + store.addContacts(addIds); + }, []); + + return ( + props.navigation.goBack()} + addContacts={handleAddContacts} + /> + ); +} diff --git a/packages/app/features/settings/AppInfoScreen.tsx b/packages/app/features/settings/AppInfoScreen.tsx index 584c120e2c..39c9dace94 100644 --- a/packages/app/features/settings/AppInfoScreen.tsx +++ b/packages/app/features/settings/AppInfoScreen.tsx @@ -1,6 +1,5 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { useDebugStore } from '@tloncorp/shared'; -import { getCurrentUserId } from '@tloncorp/shared/api'; import * as store from '@tloncorp/shared/store'; import { AppSetting, @@ -23,6 +22,7 @@ import { getEmailClients, openComposer } from 'react-native-email-link'; import { ScrollView } from 'react-native-gesture-handler'; import { NOTIFY_PROVIDER, NOTIFY_SERVICE } from '../../constants'; +import { useCurrentUserId } from '../../hooks/useCurrentUser'; import { useTelemetry } from '../../hooks/useTelemetry'; import { setDebug } from '../../lib/debug'; import { getEasUpdateDisplay } from '../../lib/platformHelpers'; @@ -32,13 +32,17 @@ const BUILD_VERSION = `${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${Applicatio type Props = NativeStackScreenProps; -function makeDebugEmail(appInfo: any, platformInfo: any) { +function makeDebugEmail( + appInfo: any, + platformInfo: any, + currentUserId: string +) { return ` ---------------------------------------------- Insert description of problem here. ---------------------------------------------- -Tlon ID: ${getCurrentUserId()} +Tlon ID: ${currentUserId} Platform Information: ${JSON.stringify(platformInfo)} @@ -54,6 +58,7 @@ export function AppInfoScreen(props: Props) { const easUpdateDisplay = useMemo(() => getEasUpdateDisplay(Updates), []); const [hasClients, setHasClients] = useState(true); const telemetry = useTelemetry(); + const currentUserId = useCurrentUserId(); const [telemetryDisabled, setTelemetryDisabled] = useState( telemetry.optedOut ); @@ -105,10 +110,10 @@ export function AppInfoScreen(props: Props) { openComposer({ to: 'support@tlon.io', - subject: `${getCurrentUserId()} uploaded logs ${id}`, - body: makeDebugEmail(appInfo, platformInfo), + subject: `${currentUserId} uploaded logs ${id}`, + body: makeDebugEmail(appInfo, platformInfo, currentUserId), }); - }, [hasClients]); + }, [hasClients, currentUserId]); return ( diff --git a/packages/app/features/settings/ThemeScreen.tsx b/packages/app/features/settings/ThemeScreen.tsx index 363681a29b..824948c006 100644 --- a/packages/app/features/settings/ThemeScreen.tsx +++ b/packages/app/features/settings/ThemeScreen.tsx @@ -71,7 +71,7 @@ export function ThemeScreen(props: Props) { }, []); return ( - <> + props.navigation.goBack()} @@ -106,6 +106,6 @@ export function ThemeScreen(props: Props) { ))} - + ); } diff --git a/packages/app/features/settings/UserBugReportScreen.tsx b/packages/app/features/settings/UserBugReportScreen.tsx index f303819521..c6cb3ced34 100644 --- a/packages/app/features/settings/UserBugReportScreen.tsx +++ b/packages/app/features/settings/UserBugReportScreen.tsx @@ -43,7 +43,7 @@ export function UserBugReportScreen({ navigation }: Props) { ); return ( - + navigation.goBack()} diff --git a/packages/app/features/top/ChatListScreen.tsx b/packages/app/features/top/ChatListScreen.tsx index 949c6f4af9..de48a4cca7 100644 --- a/packages/app/features/top/ChatListScreen.tsx +++ b/packages/app/features/top/ChatListScreen.tsx @@ -54,23 +54,17 @@ export function ChatListScreenView({ const [screenTitle, setScreenTitle] = useState('Home'); const [inviteSheetGroup, setInviteSheetGroup] = useState(); const chatOptionsSheetRef = useRef(null); - const [longPressedChat, setLongPressedChat] = useState< - db.Channel | db.Group | null - >(null); + const [longPressedChat, setLongPressedChat] = useState(null); const chatOptionsGroupId = useMemo(() => { - if (!longPressedChat) { - return; - } - return logic.isGroup(longPressedChat) - ? longPressedChat.id - : longPressedChat.group?.id; + return longPressedChat?.type === 'group' + ? longPressedChat.group.id + : undefined; }, [longPressedChat]); const chatOptionsChannelId = useMemo(() => { - if (!longPressedChat || logic.isGroup(longPressedChat)) { - return; - } - return longPressedChat.id; + return longPressedChat?.type === 'channel' + ? longPressedChat.channel.id + : undefined; }, [longPressedChat]); const [activeTab, setActiveTab] = useState<'all' | 'groups' | 'messages'>( @@ -87,9 +81,7 @@ export function ChatListScreenView({ enabled: isFocused, }); const pinned = useMemo(() => pins ?? [], [pins]); - const { data: pendingChats } = store.usePendingChats({ - enabled: isFocused, - }); + const { data: chats } = store.useCurrentChats({ enabled: isFocused, }); @@ -149,9 +141,9 @@ export function ChatListScreenView({ return { pinned: chats?.pinned ?? [], unpinned: chats?.unpinned ?? [], - pendingChats: pendingChats ?? [], + pending: chats?.pending ?? [], }; - }, [chats, pendingChats]); + }, [chats]); const handleNavigateToFindGroups = useCallback(() => { setAddGroupOpen(false); @@ -183,41 +175,39 @@ export function ChatListScreenView({ const [isChannelSwitcherEnabled] = useFeatureFlag('channelSwitcher'); const onPressChat = useCallback( - (item: db.Channel | db.Group) => { - if (logic.isGroup(item)) { + (item: db.Chat) => { + if (item.type === 'group' && item.isPending) { setSelectedGroupId(item.id); - } else if ( - item.group && - !isChannelSwitcherEnabled && - // Should navigate to channel if it's pinned as a channel - (!item.pin || item.pin.type === 'group') - ) { + } else if (item.type === 'group' && !isChannelSwitcherEnabled) { navigation.navigate('GroupChannels', { groupId: item.group.id }); + } else if (item.type === 'group') { + if (!item.group.channels?.length) { + throw new Error('cant open group with no channels'); + } + navigation.navigate('Channel', { + channelId: item.group.channels[0].id, + groupId: item.group.id, + }); } else { const screenName = screenNameFromChannelId(item.id); - navigation.navigate(screenName, { channelId: item.id, - selectedPostId: item.firstUnreadPostId, }); } }, [isChannelSwitcherEnabled, navigation] ); - const onLongPressChat = useCallback((item: db.Channel | db.Group) => { - if (logic.isChannel(item) && !item.isDmInvite) { - setLongPressedChat(item); - if (item.pin?.type === 'channel' || !item.group) { - chatOptionsSheetRef.current?.open(item.id, item.type); - } else { - chatOptionsSheetRef.current?.open( - item.group.id, - 'group', - item.group.unread?.count ?? undefined - ); - } + const onLongPressChat = useCallback((item: db.Chat) => { + if (item.isPending) { + return; } + setLongPressedChat(item); + chatOptionsSheetRef.current?.open( + item.id, + item.type === 'channel' ? item.channel.type : 'group', + item.unreadCount + ); }, []); const handleGroupPreviewSheetOpenChange = useCallback((open: boolean) => { @@ -234,7 +224,9 @@ export function ChatListScreenView({ const isTlonEmployee = useMemo(() => { const allChats = [...resolvedChats.pinned, ...resolvedChats.unpinned]; - return !!allChats.find((obj) => obj.groupId === TLON_EMPLOYEE_GROUP); + return !!allChats.find( + (chat) => chat.type === 'group' && chat.group.id === TLON_EMPLOYEE_GROUP + ); }, [resolvedChats]); useEffect(() => { @@ -336,7 +328,7 @@ export function ChatListScreenView({ setActiveTab={setActiveTab} pinned={resolvedChats.pinned} unpinned={resolvedChats.unpinned} - pendingChats={resolvedChats.pendingChats} + pending={resolvedChats.pending} onLongPressItem={onLongPressChat} onPressItem={onPressChat} onSectionChange={handleSectionChange} diff --git a/packages/app/features/top/ContactsScreen.tsx b/packages/app/features/top/ContactsScreen.tsx index b9b570b101..67a8ebd6a4 100644 --- a/packages/app/features/top/ContactsScreen.tsx +++ b/packages/app/features/top/ContactsScreen.tsx @@ -63,9 +63,15 @@ export default function ContactsScreen(props: Props) { return ( - + navigate('AddContacts')} + /> + } rightControls={ + {}} @@ -106,21 +125,20 @@ export default { onSearchQueryChange={() => {}} showSearchInput={false} pinned={[groupWithLongTitle, groupWithImage].map((g) => - makeChannelSummary({ group: g }) + makeChat({ group: g }) )} unpinned={[ groupWithColorAndNoImage, groupWithImage, - groupWithSvgImage, groupWithNoColorOrImage, - ].map((g) => makeChannelSummary({ group: g }))} - pendingChats={[]} + ].map((g) => makeChat({ group: g }))} + pending={[]} onSearchToggle={() => {}} /> ), emptyPinned: ( - + {}} @@ -131,8 +149,8 @@ export default { groupWithImage, groupWithSvgImage, groupWithNoColorOrImage, - ].map((g) => makeChannelSummary({ group: g }))} - pendingChats={[]} + ].map((g) => makeChat({ group: g }))} + pending={[]} searchQuery="" onSearchQueryChange={() => {}} onSearchToggle={() => {}} @@ -140,14 +158,14 @@ export default { ), loading: ( - + {}} showSearchInput={false} pinned={[]} unpinned={[]} - pendingChats={[]} + pending={[]} searchQuery="" onSearchQueryChange={() => {}} onSearchToggle={() => {}} diff --git a/packages/app/fixtures/fakeData.ts b/packages/app/fixtures/fakeData.ts index 5ff3e8224b..aaaf7b413a 100644 --- a/packages/app/fixtures/fakeData.ts +++ b/packages/app/fixtures/fakeData.ts @@ -804,7 +804,6 @@ export const groupWithColorAndNoImage: db.Group = { currentUserIsHost: false, title: 'Test Group', privacy: 'private', - unreadCount: 1, iconImage: null, iconImageColor: '#FF00FF', coverImage: null, @@ -813,7 +812,6 @@ export const groupWithColorAndNoImage: db.Group = { currentUserIsMember: true, lastPostId: 'test-post', lastPostAt: dates.now, - lastChannel: tlonLocalSupport.title, lastPost: { ...createFakePost() }, }; @@ -822,7 +820,6 @@ export const groupWithLongTitle: db.Group = { id: '~nibset-napwyn/tlon/long-title', title: 'And here, a reallly long title, wazzup, ok', lastPostAt: dates.earlierToday, - lastChannel: tlonLocalSupport.title, lastPost: { ...createFakePost(), textContent: @@ -836,8 +833,6 @@ export const groupWithNoColorOrImage: db.Group = { iconImageColor: null, lastPost: createFakePost(), lastPostAt: dates.yesterday, - lastChannel: tlonLocalSupport.title, - unreadCount: Math.floor(random() * 20), }; export const groupWithImage: db.Group = { @@ -847,8 +842,6 @@ export const groupWithImage: db.Group = { 'https://dans-gifts.s3.amazonaws.com/dans-gifts/solfer-magfed/2024.4.6..15.49.54..4a7e.f9db.22d0.e560-IMG_4770.jpg', lastPost: createFakePost(), lastPostAt: dates.lastWeek, - lastChannel: tlonLocalSupport.title, - unreadCount: Math.floor(random() * 20), }; export const groupWithSvgImage: db.Group = { @@ -857,8 +850,6 @@ export const groupWithSvgImage: db.Group = { iconImage: 'https://tlon.io/local-icon.svg', lastPost: createFakePost(), lastPostAt: dates.lastMonth, - lastChannel: tlonLocalSupport.title, - unreadCount: Math.floor(random() * 20), }; function randInt(min: number, max: number) { diff --git a/packages/app/hooks/useBootSequence.ts b/packages/app/hooks/useBootSequence.ts index 64d00576d8..747901dac4 100644 --- a/packages/app/hooks/useBootSequence.ts +++ b/packages/app/hooks/useBootSequence.ts @@ -221,7 +221,7 @@ export function useBootSequence({ if (dmIsGood && groupIsGood) { logger.crumb('successfully accepted invites'); if (updatedTlonTeamDm) { - store.pinItem(updatedTlonTeamDm); + store.pinChannel(updatedTlonTeamDm); } return NodeBootPhase.READY; } diff --git a/packages/app/hooks/useGroupContext.ts b/packages/app/hooks/useGroupContext.ts index 0ffae7b75b..8ea6dc9cb4 100644 --- a/packages/app/hooks/useGroupContext.ts +++ b/packages/app/hooks/useGroupContext.ts @@ -231,7 +231,7 @@ export const useGroupContext = ({ const togglePinned = useCallback(async () => { if (group && group.channels[0]) { - group.pin ? store.unpinItem(group.pin) : store.pinItem(group.channels[0]); + group.pin ? store.unpinItem(group.pin) : store.pinGroup(group); } }, [group]); diff --git a/packages/app/hooks/useTelemetry.ts b/packages/app/hooks/useTelemetry.ts index 707d7493f9..ebf2c82432 100644 --- a/packages/app/hooks/useTelemetry.ts +++ b/packages/app/hooks/useTelemetry.ts @@ -7,7 +7,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useShip } from '../contexts/ship'; import { TelemetryClient } from '../types/telemetry'; -import { useCurrentUserId } from './useCurrentUser.native'; +import { useCurrentUserId } from './useCurrentUser'; import { usePosthog } from './usePosthog'; export function useClearTelemetryConfig() { diff --git a/packages/app/navigation/RootStack.tsx b/packages/app/navigation/RootStack.tsx index 08a00cf724..bcda33b2b7 100644 --- a/packages/app/navigation/RootStack.tsx +++ b/packages/app/navigation/RootStack.tsx @@ -5,6 +5,7 @@ import { Platform, StatusBar } from 'react-native'; import { ChannelMembersScreen } from '../features/channels/ChannelMembersScreen'; import { ChannelMetaScreen } from '../features/channels/ChannelMetaScreen'; +import { AddContactsScreen } from '../features/contacts/AddContactsScreen'; import { AppInfoScreen } from '../features/settings/AppInfoScreen'; import { BlockedUsersScreen } from '../features/settings/BlockedUsersScreen'; import { EditProfileScreen } from '../features/settings/EditProfileScreen'; @@ -81,6 +82,7 @@ export function RootStack() { /> {/* individual screens */} + { headerShown: false, drawerStyle: { width: 340, + backgroundColor: getVariableValue(useTheme().background), }, }} > @@ -68,9 +70,9 @@ function MainStack() { screenOptions={{ headerShown: false, }} - initialRouteName="ChatList" + initialRouteName="Home" > - + ; } diff --git a/packages/app/navigation/desktop/ProfileScreenNavigator.tsx b/packages/app/navigation/desktop/ProfileScreenNavigator.tsx index 99476e3a8b..9e46d44e71 100644 --- a/packages/app/navigation/desktop/ProfileScreenNavigator.tsx +++ b/packages/app/navigation/desktop/ProfileScreenNavigator.tsx @@ -1,5 +1,6 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { AddContactsScreen } from '../../features/contacts/AddContactsScreen'; import { AppInfoScreen } from '../../features/settings/AppInfoScreen'; import { BlockedUsersScreen } from '../../features/settings/BlockedUsersScreen'; import { EditProfileScreen } from '../../features/settings/EditProfileScreen'; @@ -7,7 +8,9 @@ import { FeatureFlagScreen } from '../../features/settings/FeatureFlagScreen'; import { ManageAccountScreen } from '../../features/settings/ManageAccountScreen'; import ProfileScreen from '../../features/settings/ProfileScreen'; import { PushNotificationSettingsScreen } from '../../features/settings/PushNotificationSettingsScreen'; +import { ThemeScreen } from '../../features/settings/ThemeScreen'; import { UserBugReportScreen } from '../../features/settings/UserBugReportScreen'; +import ContactsScreen from '../../features/top/ContactsScreen'; import { UserProfileScreen } from '../../features/top/UserProfileScreen'; const ProfileScreenStack = createNativeStackNavigator(); @@ -15,15 +18,17 @@ const ProfileScreenStack = createNativeStackNavigator(); export const ProfileScreenNavigator = () => { return ( + + { name="EditProfile" component={EditProfileScreen} /> + ); }; diff --git a/packages/app/navigation/desktop/TopLevelDrawer.tsx b/packages/app/navigation/desktop/TopLevelDrawer.tsx index 6ff8c53641..2e8e3b414a 100644 --- a/packages/app/navigation/desktop/TopLevelDrawer.tsx +++ b/packages/app/navigation/desktop/TopLevelDrawer.tsx @@ -4,6 +4,7 @@ import { } from '@react-navigation/drawer'; import * as store from '@tloncorp/shared/store'; import { AvatarNavIcon, NavIcon, YStack, useWebAppUpdate } from '@tloncorp/ui'; +import { getVariableValue, useTheme } from 'tamagui'; import { ActivityScreen } from '../../features/top/ActivityScreen'; import { useCurrentUserId } from '../../hooks/useCurrentUser'; @@ -72,6 +73,8 @@ export const TopLevelDrawer = () => { headerShown: false, drawerStyle: { width: 48, + backgroundColor: getVariableValue(useTheme().background), + borderRightColor: getVariableValue(useTheme().border), }, }} > diff --git a/packages/app/navigation/types.ts b/packages/app/navigation/types.ts index 8f58b45694..2340cc3917 100644 --- a/packages/app/navigation/types.ts +++ b/packages/app/navigation/types.ts @@ -50,6 +50,7 @@ export type RootStackParamList = { BlockedUsers: undefined; AppInfo: undefined; PushNotificationSettings: undefined; + AddContacts: undefined; UserProfile: { userId: string; }; diff --git a/packages/shared/src/api/activityApi.ts b/packages/shared/src/api/activityApi.ts index 80c0dbe44f..4a87b0597f 100644 --- a/packages/shared/src/api/activityApi.ts +++ b/packages/shared/src/api/activityApi.ts @@ -847,17 +847,19 @@ export function getThreadSource({ return { source, sourceId }; } -export function getRootSourceFromChannel(channel: db.Channel): { +export function getRootSourceFromChat(chat: db.Chat): { source: ub.Source; sourceId: string; } { let source: ub.Source; - if (channel.type === 'dm') { - source = { dm: { ship: channel.id } }; - } else if (channel.type === 'groupDm') { - source = { dm: { club: channel.id } }; + if (chat.type === 'group') { + source = { group: chat.id }; + } else if (chat.channel.type === 'dm') { + source = { dm: { ship: chat.channel.id } }; + } else if (chat.channel.type === 'groupDm') { + source = { dm: { club: chat.channel.id } }; } else { - source = { group: channel.groupId! }; + throw new Error('Cannot get source for non-dm channel chat'); } const sourceId = ub.sourceToString(source); diff --git a/packages/shared/src/api/contactsApi.ts b/packages/shared/src/api/contactsApi.ts index 5e837d46ba..9f720645e0 100644 --- a/packages/shared/src/api/contactsApi.ts +++ b/packages/shared/src/api/contactsApi.ts @@ -95,6 +95,14 @@ export const addContact = async (contactId: string) => { }); }; +// TODO: once we can add in bulk from the backend, do so +export const addUserContacts = async (contactIds: string[]) => { + const promises = contactIds.map((contactId) => { + return addContact(contactId); + }); + return Promise.all(promises); +}; + export const removeContact = async (contactId: string) => { return poke({ app: 'contacts', @@ -247,6 +255,7 @@ export const v0PeerToClientProfile = ( isContactSuggestion?: boolean; } ): db.Contact => { + const currentUserId = getCurrentUserId(); return { id, peerNickname: contact?.nickname ?? null, @@ -262,7 +271,7 @@ export const v0PeerToClientProfile = ( })) ?? [], isContact: false, - isContactSuggestion: config?.isContactSuggestion, + isContactSuggestion: config?.isContactSuggestion && id !== currentUserId, }; }; @@ -287,6 +296,7 @@ export const v1PeerToClientProfile = ( isContactSuggestion?: boolean; } ): db.Contact => { + const currentUserId = getCurrentUserId(); return { id, peerNickname: contact.nickname?.value ?? null, @@ -301,7 +311,8 @@ export const v1PeerToClientProfile = ( contactId: id, })) ?? [], isContact: config?.isContact, - isContactSuggestion: config?.isContactSuggestion && !config?.isContact, + isContactSuggestion: + config?.isContactSuggestion && !config?.isContact && id !== currentUserId, }; }; diff --git a/packages/shared/src/db/queries.test.ts b/packages/shared/src/db/queries.test.ts index a45fda48ac..a58e31ea71 100644 --- a/packages/shared/src/db/queries.test.ts +++ b/packages/shared/src/db/queries.test.ts @@ -42,11 +42,15 @@ test('uses init data to get chat list', async () => { await syncInitData(); const result = await queries.getChats(); - expect(result.map((r) => r.id).slice(0, 8)).toEqual([ + + expect(result.pinned.map((r) => r.id)).toEqual([ '0v4.00000.qd6oi.a3f6t.5sd9v.fjmp2', - 'chat/~nibset-napwyn/commons', + ]); + + expect(result.unpinned.map((r) => r.id).slice(0, 7)).toEqual([ + '~nibset-napwyn/tlon', '~nocsyx-lassul', - 'chat/~pondus-watbel/new-channel', + '~pondus-watbel/testing-facility', '~ravseg-nosduc', '~solfer-magfed', '~hansel-ribbur', diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 25b4974385..cce64ed748 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -79,7 +79,7 @@ import { ActivityEvent, Channel, ChannelUnread, - ChatMember, + Chat, ClientMeta, Contact, Group, @@ -196,33 +196,6 @@ export const getGroups = createReadQuery( ] ); -export const getPendingChats = createReadQuery( - 'getPendingChats', - async (ctx: QueryCtx) => { - const pendingGroups = await ctx.db.query.groups.findMany({ - where: or( - eq($groups.haveInvite, true), - isNotNull($groups.joinStatus), - eq($groups.haveRequestedInvite, true) - ), - }); - - const pendingChannels = await ctx.db.query.channels.findMany({ - where: eq($channels.isDmInvite, true), - with: { - members: { - with: { - contact: true, - }, - }, - }, - }); - - return [...pendingChannels, ...pendingGroups]; - }, - ['groups', 'channels'] -); - export const getUnjoinedGroupChannels = createReadQuery( 'getUnjoinedGroupChannels', async (groupId: string, ctx: QueryCtx) => { @@ -315,119 +288,106 @@ export const getAllChannels = createReadQuery( export const getChats = createReadQuery( 'getChats', - async (ctx: QueryCtx): Promise => { - const partitionedGroupsQuery = ctx.db - .select({ - ...getTableColumns($channels), - rowNumber: - sql`ROW_NUMBER() OVER(PARTITION BY ${$channels.groupId} ORDER BY COALESCE(${$channels.lastPostAt}, ${$channelUnreads.updatedAt}) DESC)`.as( - 'row_number' - ), - }) - .from($channels) - .where( - and(isNotNull($channels.groupId), eq($groups.currentUserIsMember, true)) - ) - .leftJoin($channelUnreads, eq($channelUnreads.channelId, $channels.id)) - .leftJoin($groups, eq($groups.id, $channels.groupId)) - .as('q'); - - const groupChannels = ctx.db - .select() - .from(partitionedGroupsQuery) - .where(eq(partitionedGroupsQuery.rowNumber, 1)); + async ( + ctx: QueryCtx + ): Promise<{ pinned: Chat[]; pending: Chat[]; unpinned: Chat[] }> => { + const groups = await ctx.db.query.groups.findMany({ + where: or( + eq($groups.currentUserIsMember, true), + eq($groups.isNew, true), + isNotNull($groups.joinStatus) + ), + with: { + volumeSettings: true, + unread: true, + channels: { + orderBy: [desc($channels.lastPostAt)], + with: { + lastPost: true, + }, + }, + // Just need the first 3 members for possible title generation purposes + members: { + limit: 3, + orderBy: [asc($chatMembers.joinedAt)], + with: { + contact: true, + }, + }, + pin: true, + lastPost: true, + }, + }); - const allChannels = ctx.db - .select({ - ...getTableColumns($channels), - rowNumber: sql`0`.as('row_number'), - }) - .from($channels) - .where(and(isNull($channels.groupId), eq($channels.isDmInvite, false))) - .union(groupChannels) - .as('ac'); + const channels = await ctx.db.query.channels.findMany({ + where: isNull($channels.groupId), + with: { + volumeSettings: true, + unread: true, + members: { + with: { + contact: true, + }, + }, + pin: true, + lastPost: true, + }, + }); - const $groupVolumeSettings = ctx.db - .select() - .from($volumeSettings) - .where(eq($volumeSettings.itemType, 'group')) - .as('gvs'); + const groupChats: Chat[] = groups.map((g) => ({ + id: g.id, + type: 'group', + pin: g.pin, + timestamp: g.unread?.updatedAt ?? g.lastPostAt ?? 0, + volumeSettings: g.volumeSettings, + unreadCount: g.unread?.count ?? 0, + group: g, + isPending: + g.haveInvite === true || + !!g.joinStatus || + g.haveRequestedInvite || + false, + })); - const result = await ctx.db - .select({ - ...allQueryColumns(allChannels), - group: getTableColumns($groups), - groupUnread: getTableColumns($groupUnreads), - groupVolumeSettings: allQueryColumns($groupVolumeSettings), - volumeSettings: getTableColumns($volumeSettings), - unread: getTableColumns($channelUnreads), - pin: getTableColumns($pins), - lastPost: getTableColumns($posts), - member: { - ...getTableColumns($chatMembers), - }, - contact: getTableColumns($contacts), - }) - .from(allChannels) - .leftJoin( - $groupVolumeSettings, - eq($groupVolumeSettings.itemId, allChannels.groupId) - ) - .leftJoin($volumeSettings, eq($volumeSettings.itemId, allChannels.id)) - .leftJoin($groups, eq($groups.id, allChannels.groupId)) - .leftJoin($groupUnreads, eq($groupUnreads.groupId, allChannels.groupId)) - .leftJoin($channelUnreads, eq($channelUnreads.channelId, allChannels.id)) - .leftJoin( - $pins, - or( - eq(allChannels.groupId, $pins.itemId), - eq(allChannels.id, $pins.itemId) - ) - ) - .leftJoin($posts, eq($posts.id, allChannels.lastPostId)) - .leftJoin($chatMembers, eq($chatMembers.chatId, allChannels.id)) - .leftJoin($contacts, eq($contacts.id, $chatMembers.contactId)) - .orderBy( - ascNullsLast($pins.index), - sql`(CASE WHEN ${$groups.isNew} = 1 THEN 1 ELSE 0 END) DESC`, - sql`COALESCE(${$channelUnreads.updatedAt}, ${allChannels.lastPostAt}) DESC` - ); + const channelChats: Chat[] = channels.map((c) => ({ + id: c.id, + type: 'channel', + channel: c, + pin: c.pin, + volumeSettings: c.volumeSettings, + unreadCount: c.unread?.count ?? 0, + timestamp: c.unread?.updatedAt ?? c.lastPostAt ?? 0, + isPending: !!c.isDmInvite, + })); - const [chatMembers, filteredChannels] = result.reduce< - [ - Record, - typeof result, - ] - >( - ([members, filteredChannels], channel) => { - if (!channel.member || !members[channel.id]) { - filteredChannels.push(channel); - } - if (channel.member) { - members[channel.id] ||= []; - members[channel.id].push({ - ...channel.member, - contact: channel.contact ?? null, - }); + const { pinnedChats, pendingChats, otherChats } = [ + ...channelChats, + ...groupChats, + ].reduce( + (acc, chat) => { + if (chat.pin) { + acc.pinnedChats.push(chat); + } else if (chat.isPending) { + acc.pendingChats.push(chat); + } else { + acc.otherChats.push(chat); } - return [members, filteredChannels]; + return acc; }, - [{}, [] as typeof result] + { + pinnedChats: [], + pendingChats: [], + otherChats: [], + } as Record ); - return filteredChannels.map((c) => { - return { - ...c, - members: chatMembers[c.id] ?? null, - group: !c.group - ? null - : { - ...c.group, - unread: c.groupUnread, - volumeSettings: c.groupVolumeSettings, - }, - }; - }); + return { + pinned: pinnedChats.sort( + (a, b) => (a.pin?.index ?? 0) - (b.pin?.index ?? 0) + ), + pending: pendingChats, + unpinned: otherChats.sort((a, b) => b.timestamp - a.timestamp), + }; }, [ 'groups', @@ -2851,10 +2811,12 @@ export const getUserContacts = createReadQuery( export const getSuggestedContacts = createReadQuery( 'getSuggestedContacts', async (ctx: QueryCtx) => { + const currentUserId = getCurrentUserId(); return ctx.db.query.contacts.findMany({ where: and( eq($contacts.isContact, false), - eq($contacts.isContactSuggestion, true) + eq($contacts.isContactSuggestion, true), + not(eq($contacts.id, currentUserId)) ), with: { pinnedGroups: { diff --git a/packages/shared/src/db/types.ts b/packages/shared/src/db/types.ts index 5a7933f91a..21a6cb194f 100644 --- a/packages/shared/src/db/types.ts +++ b/packages/shared/src/db/types.ts @@ -44,12 +44,8 @@ export type ActivityEvent = BaseModel<'activityEvents'>; export type ActivityEventContactUpdateGroups = ActivityEvent['contactUpdateGroups']; export type ActivityBucket = schema.ActivityBucket; -// TODO: We need to include unread count here because it's returned by the chat -// list query, but doesn't feel great. -export type Group = BaseModel<'groups'> & { - unreadCount?: number | null; - lastChannel?: string | null; -}; +export type Group = BaseModel<'groups'>; + export type ClientMeta = Pick< Group, | 'title' @@ -100,6 +96,21 @@ export type Settings = BaseModel<'settings'>; export type PostWindow = BaseModel<'postWindows'>; export type VolumeSettings = BaseModel<'volumeSettings'>; +export type Chat = { + id: string; + pin: Pin | null; + volumeSettings: VolumeSettings | null; + timestamp: number; + isPending: boolean; + unreadCount: number; +} & ({ type: 'group'; group: Group } | { type: 'channel'; channel: Channel }); + +export interface GroupedChats { + pinned: Chat[]; + unpinned: Chat[]; + pending: Chat[]; +} + export interface GroupEvent extends ActivityEvent { type: 'group-ask'; group: Group; diff --git a/packages/shared/src/logic/utils.ts b/packages/shared/src/logic/utils.ts index d997b58ed7..2d44a104f2 100644 --- a/packages/shared/src/logic/utils.ts +++ b/packages/shared/src/logic/utils.ts @@ -207,30 +207,12 @@ export function normalizeUrbitColor(color: string): string { return color; } - const colorString = color.slice(2).replace('.', '').toUpperCase(); - const lengthAdjustedColor = colorString.padStart(6, '0'); + const noDots = color.replace('.', ''); + const prefixStripped = color.startsWith('0x') ? noDots.slice(2) : noDots; + const lengthAdjustedColor = prefixStripped.toUpperCase().padStart(6, '0'); return `#${lengthAdjustedColor}`; } -export function getPinPartial(channel: db.Channel): { - type: db.PinType; - itemId: string; -} { - if (channel.groupId) { - return { type: 'group', itemId: channel.groupId }; - } - - if (channel.type === 'dm') { - return { type: 'dm', itemId: channel.id }; - } - - if (channel.type === 'groupDm') { - return { type: 'groupDm', itemId: channel.id }; - } - - return { type: 'channel', itemId: channel.id }; -} - const MS_PER_DAY = 24 * 60 * 60 * 1000; const timezoneOffset = new Date().getTimezoneOffset() * 60 * 1000; diff --git a/packages/shared/src/store/activityActions.ts b/packages/shared/src/store/activityActions.ts index 3b9fdb233f..5d427e97fb 100644 --- a/packages/shared/src/store/activityActions.ts +++ b/packages/shared/src/store/activityActions.ts @@ -7,26 +7,26 @@ import { whomIsMultiDm } from '../urbit'; const logger = createDevLogger('activityActions', false); -export async function muteChat(channel: db.Channel) { - const initialSettings = await getChatVolumeSettings(channel); - const muteLevel = channel.groupId ? 'soft' : 'hush'; +export async function muteChat(chat: db.Chat) { + const initialSettings = await getChatVolumeSettings(chat); + const muteLevel = chat.type === 'group' ? 'soft' : 'hush'; db.setVolumes({ volumes: [ { - itemId: channel.groupId ?? channel.id, - itemType: channel.groupId ? 'group' : 'channel', + itemId: chat.id, + itemType: chat.type, level: muteLevel, }, ], }); try { - const { source } = api.getRootSourceFromChannel(channel); + const { source } = api.getRootSourceFromChat(chat); const volume = ub.getVolumeMap(muteLevel, true); await api.adjustVolumeSetting(source, volume); } catch (e) { - logger.log(`failed to mute group ${channel.id}`, e); + logger.log(`failed to mute chat ${chat.id}`, e); // revert the optimistic update if (initialSettings) { await db.setVolumes({ volumes: [initialSettings] }); @@ -34,24 +34,24 @@ export async function muteChat(channel: db.Channel) { } } -export async function unmuteChat(channel: db.Channel) { - const initialSettings = await getChatVolumeSettings(channel); +export async function unmuteChat(chat: db.Chat) { + const initialSettings = await getChatVolumeSettings(chat); db.setVolumes({ volumes: [ { - itemId: channel.groupId ?? channel.id, - itemType: channel.groupId ? 'group' : 'channel', + itemId: chat.id, + itemType: chat.type, level: 'default', }, ], }); try { - const { source } = api.getRootSourceFromChannel(channel); + const { source } = api.getRootSourceFromChat(chat); await api.adjustVolumeSetting(source, null); } catch (e) { - logger.log(`failed to unmute chat ${channel.id}`, e); + logger.log(`failed to unmute chat ${chat.id}`, e); // revert the optimistic update if (initialSettings) { await db.setVolumes({ volumes: [initialSettings] }); @@ -59,14 +59,8 @@ export async function unmuteChat(channel: db.Channel) { } } -async function getChatVolumeSettings(chat: db.Channel) { - if (chat.groupId) { - return ( - chat.group?.volumeSettings ?? (await db.getVolumeSetting(chat.groupId)) - ); - } else { - return chat.volumeSettings ?? (await db.getVolumeSetting(chat.id)); - } +async function getChatVolumeSettings(chat: db.Chat) { + return chat.volumeSettings ?? (await db.getVolumeSetting(chat.id)); } export async function muteThread({ diff --git a/packages/shared/src/store/channelActions.ts b/packages/shared/src/store/channelActions.ts index 6201e6ef35..58d2cfcd08 100644 --- a/packages/shared/src/store/channelActions.ts +++ b/packages/shared/src/store/channelActions.ts @@ -149,17 +149,32 @@ export async function updateChannel({ } } -export async function pinItem(channel: db.Channel) { - // optimistic update - const partialPin = logic.getPinPartial(channel); - db.insertPinnedItem(partialPin); +export async function pinChat(chat: db.Chat) { + return chat.type === 'group' + ? pinGroup(chat.group) + : pinChannel(chat.channel); +} + +export async function pinGroup(group: db.Group) { + return savePin({ type: 'group', itemId: group.id }); +} + +export async function pinChannel(channel: db.Channel) { + const type = + channel.type === 'dm' || channel.type === 'groupDm' + ? channel.type + : 'channel'; + return savePin({ type, itemId: channel.id }); +} +async function savePin(pin: { type: db.PinType; itemId: string }) { + db.insertPinnedItem(pin); try { - await api.pinItem(partialPin.itemId); + await api.pinItem(pin.itemId); } catch (e) { console.error('Failed to pin item', e); // rollback optimistic update - db.deletePinnedItem(partialPin); + db.deletePinnedItem(pin); } } diff --git a/packages/shared/src/store/contactActions.ts b/packages/shared/src/store/contactActions.ts index 8216b20382..edbd8658eb 100644 --- a/packages/shared/src/store/contactActions.ts +++ b/packages/shared/src/store/contactActions.ts @@ -21,6 +21,30 @@ export async function addContact(contactId: string) { } } +export async function addContacts(contacts: string[]) { + const optimisticUpdates = contacts.map((contactId) => + db.updateContact({ + id: contactId, + isContact: true, + isContactSuggestion: false, + }) + ); + await Promise.all(optimisticUpdates); + + try { + await api.addUserContacts(contacts); + } catch (e) { + // Rollback the update + const rolbacks = contacts.map((contactId) => + db.updateContact({ + id: contactId, + isContact: false, + }) + ); + await Promise.all(rolbacks); + } +} + export async function removeContact(contactId: string) { // Optimistic update await db.updateContact({ id: contactId, isContact: false }); diff --git a/packages/shared/src/store/dbHooks.ts b/packages/shared/src/store/dbHooks.ts index af7f356359..e357e5f33b 100644 --- a/packages/shared/src/store/dbHooks.ts +++ b/packages/shared/src/store/dbHooks.ts @@ -7,6 +7,7 @@ import { useMemo } from 'react'; import * as api from '../api'; import * as db from '../db'; +import { GroupedChats } from '../db/types'; import * as ub from '../urbit'; import { hasCustomS3Creds, hasHostingUploadCreds } from './storage'; import { @@ -18,14 +19,6 @@ import { keyFromQueryDeps, useKeyFromQueryDeps } from './useKeyFromQueryDeps'; export * from './useChannelSearch'; -// Assorted small hooks for fetching data from the database. -// Can break em out as they get bigger. - -export interface CurrentChats { - pinned: db.Channel[]; - unpinned: db.Channel[]; -} - export type CustomQueryConfig = Pick< UseQueryOptions, 'enabled' @@ -41,43 +34,13 @@ export const useAllChannels = ({ enabled }: { enabled?: boolean }) => { }; export const useCurrentChats = ( - queryConfig?: CustomQueryConfig -): UseQueryResult => { + queryConfig?: CustomQueryConfig +): UseQueryResult => { return useQuery({ queryFn: async () => { - const channels = await db.getChats(); - return { channels }; + return db.getChats(); }, queryKey: ['currentChats', useKeyFromQueryDeps(db.getChats)], - select({ channels }) { - for (let i = 0; i < channels.length; ++i) { - if (!channels[i].pin) { - return { - pinned: channels.slice(0, i), - unpinned: channels.slice(i), - }; - } - } - return { - pinned: channels, - unpinned: [], - }; - }, - ...queryConfig, - }); -}; - -export type PendingChats = (db.Group | db.Channel)[]; - -export const usePendingChats = ( - queryConfig?: CustomQueryConfig -): UseQueryResult => { - return useQuery({ - queryFn: async () => { - const pendingChats = await db.getPendingChats(); - return pendingChats; - }, - queryKey: ['pendingChats', useKeyFromQueryDeps(db.getPendingChats)], ...queryConfig, }); }; diff --git a/packages/shared/src/store/sync.test.ts b/packages/shared/src/store/sync.test.ts index 44f3479a5d..6dd2cbe530 100644 --- a/packages/shared/src/store/sync.test.ts +++ b/packages/shared/src/store/sync.test.ts @@ -296,9 +296,11 @@ test('syncs last posts', async () => { await syncLatestPosts(); const chats = await db.getChats(); const NUM_EMPTY_TEST_GROUPS = 6; - const chatsWithLatestPosts = chats.filter((c) => c.lastPost); + const chatsWithLatestPosts = chats.unpinned.filter((c) => + c.type === 'channel' ? c.channel.lastPost : c.group.lastPost + ); expect(chatsWithLatestPosts.length).toEqual( - chats.length - NUM_EMPTY_TEST_GROUPS + chats.unpinned.length - NUM_EMPTY_TEST_GROUPS ); }); diff --git a/packages/ui/src/components/AddContactsView.tsx b/packages/ui/src/components/AddContactsView.tsx new file mode 100644 index 0000000000..2109933c60 --- /dev/null +++ b/packages/ui/src/components/AddContactsView.tsx @@ -0,0 +1,59 @@ +import * as store from '@tloncorp/shared/store'; +import { useCallback, useMemo, useState } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { View, YStack } from 'tamagui'; + +import { Button } from './Button'; +import { ContactBook } from './ContactBook'; +import { ScreenHeader } from './ScreenHeader'; + +export function AddContactsView(props: { + goBack: () => void; + addContacts: (ids: string[]) => void; +}) { + const { bottom } = useSafeAreaInsets(); + const [newContacts, setNewContacts] = useState([]); + const handleAddContacts = useCallback(() => { + props.addContacts(newContacts); + props.goBack(); + }, [newContacts, props]); + + const { data: existingContacts } = store.useUserContacts(); + const existingIds = useMemo(() => { + return existingContacts?.map((c) => c.id) ?? []; + }, [existingContacts]); + + console.log(`existingIds`, existingIds); + + return ( + + + + + + + + + ); +} diff --git a/packages/ui/src/components/AuthorRow.tsx b/packages/ui/src/components/AuthorRow.tsx index 2f6836d14e..04944762d3 100644 --- a/packages/ui/src/components/AuthorRow.tsx +++ b/packages/ui/src/components/AuthorRow.tsx @@ -160,7 +160,7 @@ export function ChatAuthorRow({ ) : null} - {deliveryStatus && deliveryStatus !== 'failed' ? ( + {!!deliveryStatus && deliveryStatus !== 'failed' ? ( ) : null} diff --git a/packages/ui/src/components/BigInput.tsx b/packages/ui/src/components/BigInput.tsx index 3a3b58a498..7f736e9eb0 100644 --- a/packages/ui/src/components/BigInput.tsx +++ b/packages/ui/src/components/BigInput.tsx @@ -1,11 +1,9 @@ // import { EditorBridge } from '@10play/tentap-editor'; import * as db from '@tloncorp/shared/db'; -import { useMemo, useRef, useState } from 'react'; -import { Dimensions, KeyboardAvoidingView, Platform } from 'react-native'; +import { useMemo, useState } from 'react'; import { TouchableOpacity } from 'react-native-gesture-handler'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; // TODO: replace input with our own input component -import { Input, ScrollView, View, YStack, getTokenValue } from 'tamagui'; +import { Input, View, YStack, getTokenValue } from 'tamagui'; import { ImageAttachment, useAttachmentContext } from '../contexts/attachment'; import AttachmentSheet from './AttachmentSheet'; @@ -37,16 +35,8 @@ export function BigInput({ } & MessageInputProps) { const [title, setTitle] = useState(editingPost?.title ?? ''); const [showAttachmentSheet, setShowAttachmentSheet] = useState(false); - // const editorRef = useRef<{ - // editor: TlonEditorBridge | null; - // setEditor: (editor: any) => void; - // }>(null); - const { top } = useSafeAreaInsets(); - const { width } = Dimensions.get('screen'); const titleInputHeight = getTokenValue('$4xl', 'size'); const imageButtonHeight = getTokenValue('$4xl', 'size'); - const keyboardVerticalOffset = - Platform.OS === 'ios' ? top + titleInputHeight : top; const { attachments, attachAssets } = useAttachmentContext(); const imageAttachment = useMemo(() => { @@ -90,11 +80,13 @@ export function BigInput({ > {imageAttachment ? ( )} - - + {/* channelType === 'notebook' && editorRef.current && editorRef.current.editor && ( diff --git a/packages/ui/src/components/Channel/index.tsx b/packages/ui/src/components/Channel/index.tsx index 6eb23dd62d..ebc496fb47 100644 --- a/packages/ui/src/components/Channel/index.tsx +++ b/packages/ui/src/components/Channel/index.tsx @@ -316,10 +316,7 @@ export function Channel({ initialAttachments={initialAttachments} uploadAsset={uploadAsset} > - + void; - onLongPressItem?: (chat: Chat) => void; +}: db.GroupedChats & { + onPressItem?: (chat: db.Chat) => void; + onLongPressItem?: (chat: db.Chat) => void; onSectionChange?: (title: string) => void; activeTab: TabName; setActiveTab: (tab: TabName) => void; @@ -60,7 +56,7 @@ export const ChatList = React.memo(function ChatListComponent({ const displayData = useFilteredChats({ pinned, unpinned, - pending: pendingChats, + pending, searchQuery, activeTab, }); @@ -92,7 +88,7 @@ export const ChatList = React.memo(function ChatListComponent({ {item.title} ); - } else if (logic.isChannel(item)) { + } else if (item.type === 'channel' || !item.isPending) { return ( performSearch(debouncedQuery), @@ -331,42 +314,53 @@ function useFilteredChats({ }, [activeTab, pending, searchQuery, searchResults, unpinned, pinned]); } -function filterPendingChats(pending: Chat[], activeTab: TabName) { +function filterPendingChats(pending: db.Chat[], activeTab: TabName) { if (activeTab === 'all') return pending; return pending.filter((chat) => { - const isGroupChannel = logic.isGroup(chat); - return activeTab === 'groups' ? isGroupChannel : !isGroupChannel; + const isGroup = chat.type === 'group'; + return activeTab === 'groups' ? isGroup : !isGroup; }); } -function filterChats(chats: Chat[], activeTab: TabName) { +function filterChats(chats: db.Chat[], activeTab: TabName) { if (activeTab === 'all') return chats; return chats.filter((chat) => { - const isGroupChannel = logic.isGroupChannelId(chat.id); - return activeTab === 'groups' ? isGroupChannel : !isGroupChannel; + const isGroup = chat.type === 'group'; + return activeTab === 'groups' ? isGroup : !isGroup; }); } function useChatSearch({ pinned, unpinned, + pending, }: { - pinned: Chat[]; - unpinned: Chat[]; + pinned: db.Chat[]; + unpinned: db.Chat[]; + pending: db.Chat[]; }) { + const { disableNicknames } = useCalm(); + const fuse = useMemo(() => { - const allData = [...pinned, ...unpinned]; + const allData = [...pinned, ...unpinned, ...pending]; return new Fuse(allData, { keys: [ - 'id', - 'group.title', - 'contact.nickname', - 'members.contact.nickname', - 'members.contact.id', + { + name: 'title', + getFn: (chat: db.Chat) => { + const title = getChatTitle(chat, disableNicknames); + return Array.isArray(title) + ? title.map(normalizeString) + : normalizeString(title); + }, + }, ], - threshold: 0.3, }); - }, [pinned, unpinned]); + }, [pinned, unpinned, pending, disableNicknames]); + + function normalizeString(str: string) { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + } const performSearch = useCallback( (query: string) => { @@ -381,6 +375,34 @@ function useChatSearch({ return performSearch; } +function getChatTitle( + chat: db.Chat, + disableNicknames: boolean +): string | string[] { + if (chat.type === 'channel') { + if (chat.channel.title) { + return chat.channel.title; + } else if (chat.channel.members) { + return chat.channel.members + .map((member) => { + const nickname = member.contact + ? (member.contact as db.Contact).nickname + : null; + return nickname && !disableNicknames ? nickname : member.contactId; + }) + .join(', '); + } else { + return []; + } + } else { + if (chat.group.title) { + return chat.group.title; + } else { + return []; + } + } +} + function useDebouncedValue(input: T, delay: number) { const [value, setValue] = useState(input); const debouncedSetValue = useMemo( diff --git a/packages/ui/src/components/ChatMessage/ChatMessage.tsx b/packages/ui/src/components/ChatMessage/ChatMessage.tsx index ec606de16c..4b9133b167 100644 --- a/packages/ui/src/components/ChatMessage/ChatMessage.tsx +++ b/packages/ui/src/components/ChatMessage/ChatMessage.tsx @@ -4,8 +4,8 @@ import { ComponentProps, memo, useCallback, useMemo, useState } from 'react'; import { View, XStack, YStack, isWeb } from 'tamagui'; import AuthorRow from '../AuthorRow'; -import { Button } from '../Button'; import { Icon } from '../Icon'; +import { OverflowMenuButton } from '../OverflowMenuButton'; import { createContentRenderer } from '../PostContent/ContentRenderer'; import { usePostContent, @@ -196,12 +196,13 @@ const ChatMessage = ({ onPressDelete={handleDeletePressed} /> - {isWeb && !hideOverflowMenu && showOverflowOnHover && ( - - - + {!hideOverflowMenu && showOverflowOnHover && ( + )} ); diff --git a/packages/ui/src/components/ChatMessage/ChatMessageActions/MessageContainer.tsx b/packages/ui/src/components/ChatMessage/ChatMessageActions/MessageContainer.tsx index bcef0fa1f5..7ebf5c5bf5 100644 --- a/packages/ui/src/components/ChatMessage/ChatMessageActions/MessageContainer.tsx +++ b/packages/ui/src/components/ChatMessage/ChatMessageActions/MessageContainer.tsx @@ -26,7 +26,7 @@ export function MessageContainer({ post }: { post: db.Post }) { padding="$l" borderRadius="$l" > - + ); } diff --git a/packages/ui/src/components/ChatOptionsSheet.tsx b/packages/ui/src/components/ChatOptionsSheet.tsx index 8de60c3b64..caf158f489 100644 --- a/packages/ui/src/components/ChatOptionsSheet.tsx +++ b/packages/ui/src/components/ChatOptionsSheet.tsx @@ -665,7 +665,7 @@ export function ChannelOptions({ } channel.pin ? store.unpinItem(channel.pin) - : store.pinItem(channel); + : store.pinChannel(channel); }, }, ], diff --git a/packages/ui/src/components/ContactBook.tsx b/packages/ui/src/components/ContactBook.tsx index bc1b89973a..fb97444b0d 100644 --- a/packages/ui/src/components/ContactBook.tsx +++ b/packages/ui/src/components/ContactBook.tsx @@ -27,6 +27,7 @@ export function ContactBook({ searchPlaceholder = '', onSelect, multiSelect = false, + immutableIds = [], onSelectedChange, onScrollChange, explanationComponent, @@ -34,6 +35,7 @@ export function ContactBook({ height, width, }: { + immutableIds?: string[]; searchPlaceholder?: string; searchable?: boolean; onSelect?: (contactId: string) => void; @@ -46,6 +48,7 @@ export function ContactBook({ width?: number; }) { const contacts = useContacts(); + const immutableSet = useMemo(() => new Set(immutableIds), [immutableIds]); const contactsIndex = useContactIndex(); const segmentedContacts = useAlphabeticallySegmentedContacts( contacts ?? [], @@ -71,6 +74,10 @@ export function ContactBook({ const [selected, setSelected] = useState([]); const handleSelect = useCallback( (contactId: string) => { + if (immutableSet.has(contactId)) { + return; + } + if (multiSelect) { if (selected.includes(contactId)) { const newSelected = selected.filter((id) => id !== contactId); @@ -85,7 +92,7 @@ export function ContactBook({ onSelect?.(contactId); } }, - [multiSelect, onSelect, onSelectedChange, selected] + [immutableSet, multiSelect, onSelect, onSelectedChange, selected] ); const renderItem = useCallback( @@ -96,6 +103,7 @@ export function ContactBook({ backgroundColor={'$secondaryBackground'} key={item.id} contact={item} + immutable={immutableSet.has(item.id)} selectable={multiSelect} selected={isSelected} onPress={handleSelect} @@ -103,7 +111,7 @@ export function ContactBook({ /> ); }, - [selected, multiSelect, handleSelect] + [selected, immutableSet, multiSelect, handleSelect] ); const scrollPosition = useRef(0); diff --git a/packages/ui/src/components/ContactRow.tsx b/packages/ui/src/components/ContactRow.tsx index 261b0a5378..75e7d064ec 100644 --- a/packages/ui/src/components/ContactRow.tsx +++ b/packages/ui/src/components/ContactRow.tsx @@ -12,6 +12,7 @@ function ContactRowItemRaw({ contact, selected = false, selectable = false, + immutable = false, onPress, pressStyle, backgroundColor, @@ -21,6 +22,7 @@ function ContactRowItemRaw({ onPress: (id: string) => void; selectable?: boolean; selected?: boolean; + immutable?: boolean; } & Omit) { const displayName = useMemo(() => getDisplayName(contact), [contact]); @@ -56,8 +58,12 @@ function ContactRowItemRaw({ height="$4xl" width="$4xl" > - {selected ? ( - + {selected || immutable ? ( + ) : ( 0) { result.push({ - title: 'Suggested by Pals and DMs', + title: 'Suggested from %pals and DMs', data: trimmedSuggested, }); } @@ -72,7 +72,7 @@ export function ContactsScreenView(props: Props) { showNickname showEndContent endContent={ - item.isContactSuggestion ? ( + item.isContactSuggestion && !isSelf ? ( ) : isSelf ? ( diff --git a/packages/ui/src/components/GalleryPost/GalleryPost.tsx b/packages/ui/src/components/GalleryPost/GalleryPost.tsx index 5768046552..d77d0012b6 100644 --- a/packages/ui/src/components/GalleryPost/GalleryPost.tsx +++ b/packages/ui/src/components/GalleryPost/GalleryPost.tsx @@ -9,6 +9,7 @@ import { DetailViewAuthorRow } from '../AuthorRow'; import { ContactAvatar } from '../Avatar'; import { Icon } from '../Icon'; import { useBoundHandler } from '../ListItem/listItemUtils'; +import { OverflowMenuButton } from '../OverflowMenuButton'; import { createContentRenderer } from '../PostContent/ContentRenderer'; import { BlockData, @@ -17,6 +18,7 @@ import { PostContent, usePostContent, } from '../PostContent/contentUtils'; +import Pressable from '../Pressable'; import { SendPostRetrySheet } from '../SendPostRetrySheet'; import { Text } from '../TextV2'; @@ -34,6 +36,7 @@ export function GalleryPost({ onPressRetry, onPressDelete, showAuthor = true, + hideOverflowMenu, ...props }: { post: db.Post; @@ -43,8 +46,10 @@ export function GalleryPost({ onPressDelete?: (post: db.Post) => void; showAuthor?: boolean; isHighlighted?: boolean; + hideOverflowMenu?: boolean; } & Omit, 'onPress' | 'onLongPress'>) { const [showRetrySheet, setShowRetrySheet] = useState(false); + const [disableHandlePress, setDisableHandlePress] = useState(false); const handleRetryPressed = useCallback(() => { onPressRetry?.(post); @@ -71,55 +76,76 @@ export function GalleryPost({ const handleLongPress = useBoundHandler(post, onLongPress); + const onPressOverflow = useCallback(() => { + handleLongPress(); + }, [handleLongPress]); + + const onHoverIntoOverflow = useCallback(() => { + setDisableHandlePress(true); + }, []); + + const onHoverOutOfOverflow = useCallback(() => { + setDisableHandlePress(false); + }, []); + if (post.isDeleted) { return null; } return ( - - - {showAuthor && !post.hidden && !post.isDeleted && ( - - - - {deliveryFailed && ( - - Tap to retry - - )} - - - )} - - + + + {showAuthor && !post.hidden && !post.isDeleted && ( + + + + {deliveryFailed && ( + + Tap to retry + + )} + + + )} + + {!hideOverflowMenu && ( + + )} + + ); } @@ -339,7 +365,7 @@ const SmallContentRenderer = createContentRenderer({ }, image: { height: '100%', - imageProps: { aspectRatio: 'unset', height: '100%' }, + imageProps: { aspectRatio: 'unset', height: '100%', contentFit: 'cover' }, ...noWrapperPadding, }, video: { diff --git a/packages/ui/src/components/Image.tsx b/packages/ui/src/components/Image.tsx index 19f579229a..bd72d302e3 100644 --- a/packages/ui/src/components/Image.tsx +++ b/packages/ui/src/components/Image.tsx @@ -16,6 +16,8 @@ const WebImage = ({ source, style, alt, onLoad, ...props }: any) => { } }; + const { contentFit } = props; + return ( { style={{ ...StyleSheet.flatten(style), maxWidth: '100%', - height: 'auto', + height: props.height ? props.height : '100%', + objectFit: contentFit ? contentFit : undefined, }} onLoad={handleLoad} {...props} diff --git a/packages/ui/src/components/ListItem/ChannelListItem.tsx b/packages/ui/src/components/ListItem/ChannelListItem.tsx index 279763bef8..497a937a05 100644 --- a/packages/ui/src/components/ListItem/ChannelListItem.tsx +++ b/packages/ui/src/components/ListItem/ChannelListItem.tsx @@ -1,15 +1,12 @@ import type * as db from '@tloncorp/shared/db'; import * as logic from '@tloncorp/shared/logic'; import { useMemo } from 'react'; -import { View } from 'tamagui'; -import { isWeb } from 'tamagui'; -import { getVariableValue } from 'tamagui'; +import { View, isWeb } from 'tamagui'; import * as utils from '../../utils'; import { capitalize } from '../../utils'; import { Badge } from '../Badge'; import { Button } from '../Button'; -import { Chat } from '../ChatList'; import { Icon } from '../Icon'; import Pressable from '../Pressable'; import { ListItem, type ListItemProps } from './ListItem'; @@ -27,7 +24,7 @@ export function ChannelListItem({ useTypeIcon?: boolean; customSubtitle?: string; dimmed?: boolean; -} & ListItemProps & { model: db.Channel }) { +} & ListItemProps) { const unreadCount = model.unread?.count ?? 0; const title = utils.useChannelTitle(model); const firstMemberId = model.members?.[0]?.contactId ?? ''; @@ -82,7 +79,7 @@ export function ChannelListItem({ {subtitle} )} - {model.lastPost && ( + {model.lastPost && !model.isDmInvite && ( )} @@ -112,13 +107,13 @@ export function ChannelListItem({ {isWeb && ( - + diff --git a/packages/ui/src/components/ListItem/ChatListItem.tsx b/packages/ui/src/components/ListItem/ChatListItem.tsx index 5cb2b19ff0..3ccd15c93e 100644 --- a/packages/ui/src/components/ListItem/ChatListItem.tsx +++ b/packages/ui/src/components/ListItem/ChatListItem.tsx @@ -1,8 +1,7 @@ import type * as db from '@tloncorp/shared/db'; import * as logic from '@tloncorp/shared/logic'; -import React, { useMemo } from 'react'; +import React from 'react'; -import { Chat } from '../ChatList'; import { ChannelListItem } from './ChannelListItem'; import { GroupListItem } from './GroupListItem'; import { ListItemProps } from './ListItem'; @@ -12,7 +11,7 @@ export const ChatListItem = React.memo(function ChatListItemComponent({ onPress, onLongPress, ...props -}: ListItemProps) { +}: ListItemProps) { const handlePress = logic.useMutableCallback(() => { onPress?.(model); }); @@ -21,74 +20,23 @@ export const ChatListItem = React.memo(function ChatListItemComponent({ onLongPress?.(model); }); - // if the chat list item is a group, it's pending - if (logic.isGroup(model)) { + if (model.type === 'group') { return ( + ); + } else { + return ( + ); } - - if (logic.isChannel(model)) { - if ( - model.type === 'dm' || - model.type === 'groupDm' || - model.pin?.type === 'channel' - ) { - return ( - - ); - } else if (model.group) { - return ( - - ); - } - } - - console.warn('unable to render chat list item', model.id, model); - return null; }); - -function GroupListItemAdapter({ - model, - groupModel, - onPress, - onLongPress, - ...props -}: ListItemProps & { - groupModel: db.Group; - onPress: () => void; - onLongPress: () => void; -}) { - const resolvedModel = useMemo(() => { - return { - ...groupModel, - unreadCount: model.unread?.count, - lastPost: model.lastPost, - lastChannel: model.title, - }; - }, [model, groupModel]); - return ( - - ); -} diff --git a/packages/ui/src/components/ListItem/GroupListItem.tsx b/packages/ui/src/components/ListItem/GroupListItem.tsx index ab897d5d0b..1258e56fad 100644 --- a/packages/ui/src/components/ListItem/GroupListItem.tsx +++ b/packages/ui/src/components/ListItem/GroupListItem.tsx @@ -43,11 +43,11 @@ export const GroupListItem = ({ {customSubtitle && ( {customSubtitle} )} - {model.lastPost && !customSubtitle && ( + {model.lastPost && model.channels?.length && !customSubtitle && ( - {model.lastChannel} + {model.channels[0].title} )} {!isPending && model.lastPost ? ( @@ -68,7 +68,7 @@ export const GroupListItem = ({ )} @@ -77,13 +77,13 @@ export const GroupListItem = ({ {isWeb && !isPending && ( - + diff --git a/packages/ui/src/components/ListItem/InteractableChatListItem.tsx b/packages/ui/src/components/ListItem/InteractableChatListItem.tsx index 838e768cd4..b02ab42a82 100644 --- a/packages/ui/src/components/ListItem/InteractableChatListItem.tsx +++ b/packages/ui/src/components/ListItem/InteractableChatListItem.tsx @@ -20,7 +20,6 @@ import Animated, { import { ColorTokens, Stack, View, getTokenValue, isWeb } from 'tamagui'; import * as utils from '../../utils'; -import { Chat } from '../ChatList'; import { Icon, IconType } from '../Icon'; import { ChatListItem } from './ChatListItem'; import { ListItemProps } from './ListItem'; @@ -30,19 +29,14 @@ function BaseInteractableChatRow({ model, onPress, onLongPress, -}: ListItemProps & { model: db.Channel }) { +}: ListItemProps) { const swipeableRef = useRef(null); const [currentSwipeDirection, setCurrentSwipeDirection] = useState< 'left' | 'right' | null >(null); const isMuted = useMemo(() => { - if (model.group) { - return logic.isMuted(model.group.volumeSettings?.level, 'group'); - } else if (model.type === 'dm' || model.type === 'groupDm') { - return logic.isMuted(model.volumeSettings?.level, 'channel'); - } - return false; + return logic.isMuted(model.volumeSettings?.level, model.type); }, [model]); // prevent color flicker when unmuting @@ -60,16 +54,16 @@ function BaseInteractableChatRow({ utils.triggerHaptic('swipeAction'); switch (actionId) { case 'pin': - model.pin ? store.unpinItem(model.pin) : store.pinItem(model); + model.pin ? store.unpinItem(model.pin) : store.pinChat(model); break; case 'mute': isMuted ? store.unmuteChat(model) : store.muteChat(model); break; case 'markRead': - if (model.group) { + if (model.type === 'group') { store.markGroupRead(model.group, true); } else { - store.markChannelRead(model); + store.markChannelRead(model.channel); } break; default: @@ -89,9 +83,7 @@ function BaseInteractableChatRow({ const renderLeftActions = useCallback( (progress: SharedValue, drag: SharedValue) => { - const hasUnread = model.group - ? (model.group.unread?.count ?? 0) > 0 - : (model.unread?.count ?? 0) > 0; + const hasUnread = model.unreadCount > 0; if (currentSwipeDirection === 'right' || !hasUnread) { return ; @@ -105,7 +97,7 @@ function BaseInteractableChatRow({ /> ); }, - [handleAction, model.group, model.unread?.count, currentSwipeDirection] + [model.unreadCount, currentSwipeDirection, handleAction] ); const renderRightActions = useCallback( diff --git a/packages/ui/src/components/ListItem/ListItem.tsx b/packages/ui/src/components/ListItem/ListItem.tsx index b243fb237f..8edd5989fe 100644 --- a/packages/ui/src/components/ListItem/ListItem.tsx +++ b/packages/ui/src/components/ListItem/ListItem.tsx @@ -3,12 +3,11 @@ import * as db from '@tloncorp/shared/db'; import { ComponentProps, ReactElement, useMemo } from 'react'; import { getVariableValue, - isWeb, styled, useTheme, withStaticProperties, } from 'tamagui'; -import { SizableText, Stack, View, XStack, YStack } from 'tamagui'; +import { Stack, View, XStack, YStack } from 'tamagui'; import { numberWithMax } from '../../utils'; import { @@ -20,6 +19,7 @@ import { } from '../Avatar'; import ContactName from '../ContactName'; import { Icon, IconType } from '../Icon'; +import { Text } from '../TextV2'; export interface BaseListItemProps { model: T; @@ -80,14 +80,16 @@ const ListItemImageIcon = ImageAvatar; const ListItemMainContent = styled(YStack, { name: 'ListItemMainContent', flex: 1, - justifyContent: 'space-evenly', - height: isWeb ? '$5xl' : '$4xl', + justifyContent: 'space-around', + height: '$4xl', }); -const ListItemTitle = styled(SizableText, { +const ListItemTitle = styled(Text, { name: 'ListItemTitle', color: '$primaryText', numberOfLines: 1, + size: '$label/l', + paddingBottom: 1, variants: { dimmed: { true: { @@ -121,19 +123,19 @@ const ListItemSubtitleIcon = styled(Icon, { size: '$s', }); -const ListItemSubtitle = styled(SizableText, { +const ListItemSubtitle = styled(Text, { name: 'ListItemSubtitle', numberOfLines: 1, - size: '$s', + size: '$label/m', color: '$tertiaryText', }); -export const ListItemTimeText = styled(SizableText, { +export const ListItemTimeText = styled(Text, { name: 'ListItemTimeText', numberOfLines: 1, color: '$tertiaryText', - size: '$s', - lineHeight: '$xs', + size: '$label/m', + paddingBottom: '$xs', }); const ListItemTime = ListItemTimeText.styleable<{ @@ -185,9 +187,9 @@ const ListItemCount = ({ {muted && ( )} - + {numberWithMax(count, 99)} - + ); @@ -197,6 +199,7 @@ const ListItemCountNumber = styled(XStack, { name: 'ListItemCountNumber', gap: '$s', alignItems: 'center', + paddingVertical: '$s', variants: { hidden: { true: { diff --git a/packages/ui/src/components/MessageInput/MessageInputBase.tsx b/packages/ui/src/components/MessageInput/MessageInputBase.tsx index b636830e70..ba41c8ac81 100644 --- a/packages/ui/src/components/MessageInput/MessageInputBase.tsx +++ b/packages/ui/src/components/MessageInput/MessageInputBase.tsx @@ -132,6 +132,7 @@ export const MessageInputContainer = memo( gap="$l" alignItems="flex-end" justifyContent="space-between" + backgroundColor="$background" > {goBack ? ( diff --git a/packages/ui/src/components/MessageInput/index.tsx b/packages/ui/src/components/MessageInput/index.tsx index 523c41d1cf..e2cda27cfe 100644 --- a/packages/ui/src/components/MessageInput/index.tsx +++ b/packages/ui/src/components/MessageInput/index.tsx @@ -7,7 +7,6 @@ import HardBreak from '@tiptap/extension-hard-break'; import History from '@tiptap/extension-history'; import Italic from '@tiptap/extension-italic'; import Link from '@tiptap/extension-link'; -import Mention from '@tiptap/extension-mention'; import Paragraph from '@tiptap/extension-paragraph'; import Placeholder from '@tiptap/extension-placeholder'; import Strike from '@tiptap/extension-strike'; @@ -26,9 +25,7 @@ import { import * as db from '@tloncorp/shared/db'; import { Block, - Image, Inline, - JSONContent, Story, citeToPath, constructStory, @@ -37,14 +34,14 @@ import { } from '@tloncorp/shared/urbit'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Keyboard } from 'react-native'; -import { View, YStack } from 'tamagui'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { View, YStack, getTokenValue, useWindowDimensions } from 'tamagui'; import { Attachment, UploadedImageAttachment, useAttachmentContext, } from '../../contexts/attachment'; -import { Input } from '../Input'; import { AttachmentPreviewList } from './AttachmentPreviewList'; import { MessageInputContainer, MessageInputProps } from './MessageInputBase'; @@ -88,15 +85,28 @@ export function MessageInput({ waitForAttachmentUploads, } = useAttachmentContext(); - const [containerHeight, setContainerHeight] = useState(initialHeight); const [isSending, setIsSending] = useState(false); const [sendError, setSendError] = useState(false); const [hasSetInitialContent, setHasSetInitialContent] = useState(false); - const [imageOnEditedPost, setImageOnEditedPost] = useState(); const [editorIsEmpty, setEditorIsEmpty] = useState(attachments.length === 0); const [showMentionPopup, setShowMentionPopup] = useState(false); const [mentionText, setMentionText] = useState(); + const { bottom, top } = useSafeAreaInsets(); + const { height } = useWindowDimensions(); + const headerHeight = 48; + const titleInputHeight = 48; + const inputBasePadding = getTokenValue('$s', 'space'); + const imageInputButtonHeight = 50; + const basicOffset = useMemo( + () => top + headerHeight + titleInputHeight + imageInputButtonHeight, + [top, headerHeight, titleInputHeight, imageInputButtonHeight] + ); + const bigInputHeightBasic = useMemo( + () => height - basicOffset - bottom - inputBasePadding * 2, + [height, basicOffset, bottom, inputBasePadding] + ); + const extensions = [ Blockquote, Bold, @@ -159,12 +169,7 @@ export function MessageInput({ blocks, } = extractContentTypesFromPost(editingPost); - if ( - !story || - story?.length === 0 || - !postReferences || - blocks.length === 0 - ) { + if (story === null && !postReferences && blocks.length === 0) { return; } @@ -195,12 +200,27 @@ export function MessageInput({ resetAttachments(attachments); + if (story === null) { + return; + } + const tiptapContent = tiptap.diaryMixedToJSON( story.filter( (c) => !('type' in c) && !('block' in c && 'image' in c.block) ) as Story ); editor.commands.setContent(tiptapContent); + + if (editingPost.image) { + addAttachment({ + type: 'image', + file: { + uri: editingPost.image, + height: 0, + width: 0, + }, + }); + } } }); } catch (e) { @@ -209,7 +229,14 @@ export function MessageInput({ setHasSetInitialContent(true); } } - }, [editor, getDraft, hasSetInitialContent, editingPost, resetAttachments]); + }, [ + editor, + getDraft, + hasSetInitialContent, + editingPost, + resetAttachments, + addAttachment, + ]); useEffect(() => { if (editor && shouldBlur && editor.isFocused) { @@ -451,17 +478,6 @@ export function MessageInput({ return []; }); - if (imageOnEditedPost) { - blocks.push({ - image: { - src: imageOnEditedPost.image.src, - height: imageOnEditedPost.image.height, - width: imageOnEditedPost.image.width, - alt: imageOnEditedPost.image.alt, - }, - }); - } - if (blocks && blocks.length > 0) { if (channelType === 'chat') { story.unshift(...blocks.map((block) => ({ block }))); @@ -470,11 +486,27 @@ export function MessageInput({ } } + const metadata: db.PostMetadata = {}; + if (title && title.length > 0) { + metadata['title'] = title; + } + + if (image) { + const attachment = finalAttachments.find( + (a): a is UploadedImageAttachment => + a.type === 'image' && a.file.uri === image.uri + ); + if (!attachment) { + throw new Error('unable to attach image'); + } + metadata['image'] = attachment.uploadState.remoteUri; + } + if (isEdit && editingPost) { if (editingPost.parentId) { - await editPost?.(editingPost, story, editingPost.parentId); + await editPost?.(editingPost, story, editingPost.parentId, metadata); } - await editPost?.(editingPost, story); + await editPost?.(editingPost, story, undefined, metadata); setEditingPost?.(undefined); } else { const metadata: db.PostMetadata = {}; @@ -503,14 +535,12 @@ export function MessageInput({ clearAttachments(); clearDraft(); setShowBigInput?.(false); - setImageOnEditedPost(null); }, [ json, onSend, editor, waitForAttachmentUploads, - imageOnEditedPost, editingPost, clearAttachments, clearDraft, @@ -560,7 +590,7 @@ export function MessageInput({ setShouldBlur={setShouldBlur} onPressSend={handleSend} onPressEdit={handleEdit} - containerHeight={containerHeight} + containerHeight={initialHeight} mentionText={mentionText} groupMembers={groupMembers} onSelectMention={onSelectMention} @@ -585,7 +615,7 @@ export function MessageInput({ borderRadius="$xl" > {showInlineAttachments && } - + void; @@ -48,8 +51,11 @@ export function NotebookPost({ viewMode?: 'activity'; isHighlighted?: boolean; size?: '$l' | '$s' | '$xs'; + hideOverflowMenu?: boolean; }) { const [showRetrySheet, setShowRetrySheet] = useState(false); + const [disableHandlePress, setDisableHandlePress] = useState(false); + const handleLongPress = useCallback(() => { onLongPress?.(post); }, [post, onLongPress]); @@ -82,20 +88,31 @@ export function NotebookPost({ onPress?.(post); }, [post, onPress, deliveryFailed]); + const onPressOverflow = useCallback(() => { + handleLongPress(); + }, [handleLongPress]); + + const onHoverIntoOverflow = useCallback(() => { + setDisableHandlePress(true); + }, []); + + const onHoverOutOfOverflow = useCallback(() => { + setDisableHandlePress(false); + }, []); + if (!post || post.isDeleted) { return null; } const hasReplies = post.replyCount && post.replyTime && post.replyContactIds; return ( - <> - + + {post.hidden ? ( ) : null} + {!hideOverflowMenu && ( + + )} - + ); } @@ -174,7 +198,7 @@ function NotebookPostHeader({ return ( - {post.image && size !== '$xs' && ( + {!!post.image && size !== '$xs' && ( void; + backgroundColor?: string; +} & ComponentProps) { + if (!isWeb) { + return null; + } + + return ( + + + + ); +} diff --git a/packages/ui/src/components/PostScreenView.tsx b/packages/ui/src/components/PostScreenView.tsx index 5b823f122e..5ad97b1163 100644 --- a/packages/ui/src/components/PostScreenView.tsx +++ b/packages/ui/src/components/PostScreenView.tsx @@ -183,7 +183,6 @@ export function PostScreenView({ const handleGoBack = useCallback(() => { if (isEditingParent) { - console.log('setEditingPost', undefined); setEditingPost?.(undefined); if (channel.type !== 'notebook') { goBack?.(); diff --git a/packages/ui/src/components/ProfileStatusSheet.tsx b/packages/ui/src/components/ProfileStatusSheet.tsx index c7983b6c80..1b0198c2bb 100644 --- a/packages/ui/src/components/ProfileStatusSheet.tsx +++ b/packages/ui/src/components/ProfileStatusSheet.tsx @@ -1,11 +1,13 @@ import { useCallback, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { Keyboard } from 'react-native'; -import { YStack } from 'tamagui'; +import { XStack, YStack } from 'tamagui'; import { useContact, useCurrentUserId } from '../contexts'; import { ActionSheet } from './ActionSheet'; +import { Button } from './Button'; import { ControlledTextField } from './Form'; +import { Icon } from './Icon'; export default function ProfileStatusSheet({ open, @@ -57,27 +59,38 @@ export default function ProfileStatusSheet({ snapPoints={[60]} > - - + + + + + diff --git a/packages/ui/src/components/TextV2/Text.tsx b/packages/ui/src/components/TextV2/Text.tsx index 8ac9b42e0b..995f376269 100644 --- a/packages/ui/src/components/TextV2/Text.tsx +++ b/packages/ui/src/components/TextV2/Text.tsx @@ -59,7 +59,7 @@ export const typeStyles = { fontSize: 16, lineHeight: 24, letterSpacing: -0.2, - fontWeight: '500', + fontWeight: '400', }, '$label/xl': { fontSize: 17, diff --git a/packages/ui/src/contexts/chatOptions.tsx b/packages/ui/src/contexts/chatOptions.tsx index fbed826993..f69af88afb 100644 --- a/packages/ui/src/contexts/chatOptions.tsx +++ b/packages/ui/src/contexts/chatOptions.tsx @@ -74,7 +74,7 @@ export const ChatOptionsProvider = ({ const onTogglePinned = useCallback(() => { if (group && group.channels[0]) { - group.pin ? store.unpinItem(group.pin) : store.pinItem(group.channels[0]); + group.pin ? store.unpinItem(group.pin) : store.pinGroup(group); } }, [group]); diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index aaff572958..7f04b5273d 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -26,6 +26,7 @@ export * from './components/ContactList'; export { default as ContactName } from './components/ContactName'; export { useContactName } from './components/ContactNameV2'; export * from './components/ContactsScreenView'; +export * from './components/AddContactsView'; export * from './components/ContactRow'; export * from './components/ContentReference'; export * from './components/CreateGroupView'; diff --git a/packages/ui/src/utils/user.ts b/packages/ui/src/utils/user.ts index 3c7481fd19..3783b8cc35 100644 --- a/packages/ui/src/utils/user.ts +++ b/packages/ui/src/utils/user.ts @@ -23,5 +23,10 @@ export function formatUserId( } export function getDisplayName(contact: db.Contact) { - return contact.nickname ?? contact.id; + if (contact.nickname && contact.nickname.length) { + return contact.nickname; + } + + const formatted = formatUserId(contact.id); + return formatted?.display ?? contact.id; } diff --git a/tm-alpha-desk/desk.docket-0 b/tm-alpha-desk/desk.docket-0 index e92b9d7141..60af1a7c96 100644 --- a/tm-alpha-desk/desk.docket-0 +++ b/tm-alpha-desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v6.e7n2f.8e8kv.4a2da.o8jm3.2f5q6.glob' 0v6.e7n2f.8e8kv.4a2da.o8jm3.2f5q6] + glob-http+['https://bootstrap.urbit.org/glob-0v1.267eb.hn3gf.ludgo.1kct0.eas77.glob' 0v1.267eb.hn3gf.ludgo.1kct0.eas77] base+'tm-alpha' version+[1 0 0] website+'https://tlon.io'