1package tinyclient23import (4 "net/url"5 "strings"6)78// mobileHeaderData is the small, route-aware context used by the mobile shell.9// It contains only data that has already passed page access checks.10type mobileHeaderData struct {11 Title string12 Subtitle string13 BackURL string14 BackLabel string15 PubKey string16 RoomID string17 Picture string18}1920// mobileHeader derives a compact header from rendered page data. Private and21// failed room pages deliberately use generic values so hidden room metadata22// cannot appear in the shell.23func mobileHeader(data PageData) mobileHeaderData {24 if data.Private {25 return mobileHeaderData{Title: "Sign in", Subtitle: data.Slug}26 }27 if data.Tab == "signin" {28 title := "Sign in"29 if data.Actor != "" {30 title = "Account"31 if data.Query.Get("connect") != "" {32 title = "Connect signer"33 }34 }35 result := mobileHeaderData{Title: title, Subtitle: data.Slug, BackLabel: "Back"}36 if next, ok := mobileReturnPath(data.Query.Get("next"), data.Base); ok {37 result.BackURL = next38 result.BackLabel = "Back"39 }40 return result41 }42 if data.Error != "" && roomRoute(data.Path).tab != "" {43 return mobileHeaderData{Title: "Chat", BackURL: "/chat", BackLabel: "Back to chats"}44 }45 route := roomRoute(data.Path)46 switch route.tab {47 case "room":48 return roomHeader(data, route, false)49 case "thread":50 return roomHeader(data, route, true)51 }52 if data.Tab == "direct" || strings.HasPrefix(data.Path, "/chat/dm/") {53 result := mobileHeaderData{Title: "Direct chat", Subtitle: "Private conversation", BackURL: "/chat", BackLabel: "Back to chats"}54 if eventIDPattern.MatchString(data.View) {55 result.PubKey = data.View56 }57 return result58 }59 return generalMobileHeader(data)60}6162func roomHeader(data PageData, route roomPath, thread bool) mobileHeaderData {63 result := mobileHeaderData{Title: "Group chat", Subtitle: "Chat", RoomID: route.id}64 room := valueMap(valueMap(data.Event)["room"])65 if name := plainString(room["name"]); name != "" {66 result.Title = name67 }68 result.Picture = socialMediaURL(plainString(room["picture"]))69 if thread {70 result.Title = "Thread"71 result.Subtitle = plainString(room["name"])72 if result.Subtitle == "" {73 result.Subtitle = "Group chat"74 }75 root := plainString(valueMap(valueMap(data.Event)["root"])["id"])76 if !eventIDPattern.MatchString(root) {77 root = route.event78 }79 if eventIDPattern.MatchString(root) {80 result.BackURL = "/rooms/" + route.id + "#msg-" + root81 }82 result.BackLabel = "Back to room"83 return result84 }85 result.BackURL, result.BackLabel = "/chat", "Back to chats"86 return result87}8889func generalMobileHeader(data PageData) mobileHeaderData {90 path := data.Path91 result := mobileHeaderData{Title: mobileTitle(path, data), Subtitle: data.Slug, BackLabel: "Back"}92 if data.Tab == "social" {93 view := socialView(data.Query)94 result.Title, result.Subtitle = view.Label, "Social"95 if view.Key != "feed" {96 result.BackURL, result.BackLabel = "/social", "Back to feed"97 return result98 }99 }100 if data.Tab == "social-profile" {101 result.Title, result.Subtitle = "Profile", "Social"102 if data.Error == "" {103 profile := valueMap(valueMap(data.Event)["profile"])104 for _, field := range []string{"display_name", "name"} {105 if name := plainString(profile[field]); name != "" {106 result.Title = shortHeaderText(name)107 break108 }109 }110 }111 }112 if data.Tab == "social-thread" {113 result.Title = eventShortTitle(data.Event, "Social")114 result.Subtitle = "Social"115 }116 if data.Tab == "file" {117 result.Title = eventShortTitle(data.Event, "File")118 result.Subtitle = "Files"119 }120 if data.Tab == "files" && data.Query.Get("view") == "sites" {121 result.Title = "Sites"122 result.Subtitle = "Files"123 }124 if data.Tab == "repo" {125 if repo := data.Query.Get("repo"); repo != "" {126 result.Title = repo127 }128 result.Subtitle = repoView(data.Query)129 }130 if data.Tab == "wikipage" {131 result.Title = eventShortTitle(data.Event, data.Query.Get("d"))132 result.Subtitle = "Wiki"133 }134 switch {135 case path == "/" || path == "":136 case path == "/chat" || path == "/social" || path == "/files" || path == "/wiki" || path == "/repos":137 result.BackURL = "/"138 case path == "/manage" || path == "/manage/people":139 result.BackURL = "/"140 result.BackLabel = "Back to home"141 case strings.HasPrefix(path, "/manage/"):142 result.BackURL = "/manage/people"143 result.BackLabel = "Back to manage"144 case path == "/sites":145 result.BackURL = "/"146 case strings.HasPrefix(path, "/file/"):147 result.BackURL = "/files"148 result.BackLabel = "Back to files"149 case path == "/profile":150 result.BackURL = "/account"151 result.BackLabel = "Back to account"152 case strings.HasPrefix(path, "/social/"):153 result.BackURL = "/social"154 result.BackLabel = "Back to social"155 case strings.HasPrefix(path, "/files/"):156 result.BackURL = "/files"157 result.BackLabel = "Back to files"158 case strings.HasPrefix(path, "/wiki/"):159 result.BackURL = "/wiki"160 result.BackLabel = "Back to wiki"161 case strings.HasPrefix(path, "/repos/"):162 result.BackURL = "/repos"163 result.BackLabel = "Back to repositories"164 case path != "/social" && path != "/files" && path != "/wiki" && path != "/repos":165 result.BackURL = "/"166 }167 if strings.HasPrefix(path, "/manage/") {168 result.Subtitle = "Manage"169 }170 if data.Tab == "signin" {171 result.Title = "Sign in"172 result.Subtitle = data.Slug173 }174 return result175}176177func mobileReturnPath(raw, base string) (string, bool) {178 if raw == "" || strings.IndexFunc(raw, func(r rune) bool { return r < 0x20 || r == '\\' }) >= 0 {179 return "", false180 }181 u, err := url.Parse(raw)182 if err != nil || u.IsAbs() || u.Host != "" || u.Path == "/signin" || !strings.HasPrefix(u.Path, "/") {183 return "", false184 }185 decodedPath, err := url.PathUnescape(u.Path)186 if err != nil || strings.IndexFunc(decodedPath, func(r rune) bool { return r < 0x20 || r == '\\' }) >= 0 {187 return "", false188 }189 if base != "" && strings.HasPrefix(u.Path, "/r/") && !strings.HasPrefix(u.Path, strings.TrimSuffix(base, "/")+"/") {190 return "", false191 }192 return raw, true193}194195func eventShortTitle(value any, fallback string) string {196 row := valueMap(value)197 if post := valueMap(row["post"]); post != nil {198 row = post199 }200 for _, key := range []string{"name", "filename", "title", "d"} {201 if title := strings.TrimSpace(plainString(row[key])); title != "" && !eventIDPattern.MatchString(title) {202 return shortHeaderText(title)203 }204 }205 if content := strings.TrimSpace(plainString(row["content"])); content != "" {206 return shortHeaderText(strings.Split(content, "\n")[0])207 }208 return fallback209}210211func shortHeaderText(value string) string {212 value = strings.TrimSpace(value)213 if len([]rune(value)) > 64 {214 return string([]rune(value)[:61]) + "..."215 }216 return value217}218219func mobileTitle(path string, data PageData) string {220 known := map[string]string{"/": "Home", "/home": "Home", "/search": "Search", "/social": "Social", "/chat": "Chat", "/files": "Files", "/wiki": "Wiki", "/repos": "Repositories", "/approvals": "Approvals", "/profile": "Profile", "/account": "Account", "/manage": "Manage", "/manage/people": "People", "/manage/agents": "Agents", "/manage/moderation": "Moderation", "/manage/rules": "Rules", "/manage/identity": "Identity", "/manage/connect": "Connect", "/manage/owner": "Owner", "/manage/sync": "Sync", "/manage/data": "Data", "/manage/views": "Views", "/manage/health": "Health", "/sites": "Sites"}221 if title, ok := known[path]; ok {222 return title223 }224 if strings.HasPrefix(path, "/social/") {225 return "Social"226 }227 if strings.HasPrefix(path, "/invite/") {228 return "Join"229 }230 if strings.HasPrefix(path, "/wiki/") {231 return "Wiki"232 }233 if strings.HasPrefix(path, "/file/") {234 return "File"235 }236 if parseRepoRoute(path).ok {237 return "Repository"238 }239 if strings.HasPrefix(path, "/files/") || strings.HasPrefix(path, "/approvals/") {240 return known["/"+strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2)[0]]241 }242 if title := strings.TrimSuffix(data.Title, " | "+data.Slug); title != "" && !eventIDPattern.MatchString(title) {243 return title244 }245 return "Tiny"246}